Reuse SSH connections for executing multiple commands.

When using SSH client to execute a command a Paramiko
client is created, it is connected to server and then the
client reference is forgot without closing socket.

This produces a leak of SSH connections. It also slow
down test executions when more than one command has to
be executed with the same SSH client (for example when
executing ping between VMs).

This change also add convenience methods to SSH client:

- connect() method allows to create and connect Paramiko
  client to be used by tests directly (for exaple to open
  a command like socat, cat, nc and redirect STDIN/STDOUT
  to generate or receive network traffic. The method is
  going to return the same Paramiko client instance
  until close() method is called.

- close() method allows to close paramiko client socket and
  release resources.

- execute_script() spawn a script interpreter (Bash by default) on
  a remote machinge to execute a script provided as a string.
  For convenience by default it combines STDOUT and STDERR to LOG
  an human friendly message when the script fails.

Change-Id: I3a70131f03aea342c8e8a04038000bd974cca921
This commit is contained in:
Federico Ressi 2018-07-06 10:05:32 +02:00
parent bf877c84b3
commit 6f0644e271
2 changed files with 145 additions and 0 deletions

View File

@ -13,9 +13,12 @@
# under the License.
import os
import time
from oslo_log import log
import paramiko
from tempest.lib.common import ssh
from tempest.lib import exceptions
from neutron_tempest_plugin import config
@ -26,6 +29,8 @@ LOG = log.getLogger(__name__)
class Client(ssh.Client):
default_ssh_lang = 'en_US.UTF-8'
timeout = CONF.validation.ssh_timeout
proxy_jump_host = CONF.neutron_plugin_options.ssh_proxy_jump_host
@ -112,3 +117,142 @@ class Client(ssh.Client):
host=host, username=username, password=password,
look_for_keys=look_for_keys, key_filename=key_file,
port=port, proxy_client=None, **kwargs)
# attribute used to keep reference to opened client connection
_client = None
def connect(self, *args, **kwargs):
"""Creates paramiko.SSHClient and connect it to remote SSH server
In case this method is called more times it returns the same client
and no new SSH connection is created until close method is called.
:returns: paramiko.Client connected to remote server.
:raises tempest.lib.exceptions.SSHTimeout: in case it fails to connect
to remote server.
"""
client = self._client
if client is None:
client = super(Client, self)._get_ssh_connection(
*args, **kwargs)
self._client = client
return client
# This overrides superclass protected method to make sure exec_command
# method is going to reuse the same SSH client and connection if called
# more times
_get_ssh_connection = connect
def close(self):
"""Closes connection to SSH server and cleanup resources.
"""
client = self._client
if client is not None:
client.close()
self._client = None
def open_session(self):
"""Gets connection to SSH server and open a new paramiko.Channel
:returns: new paramiko.Channel
"""
client = self.connect()
try:
return client.get_transport().open_session()
except paramiko.SSHException:
# the request is rejected, the session ends prematurely or
# there is a timeout opening a channel
LOG.exception("Unable to open SSH session")
raise exceptions.SSHTimeout(host=self.host,
user=self.username,
password=self.password)
def execute_script(self, script, become_root=False,
combine_stderr=True, shell='sh -eux'):
"""Connect to remote machine and executes script.
Implementation note: it passes script lines to shell interpreter via
STDIN. Therefore script line number could be not available to some
script interpreters for debugging porposes.
:param script: script lines to be executed.
:param become_root: executes interpreter as root with sudo.
:param combine_stderr (bool): whenever to redirect STDERR to STDOUT so
that output from both streams are returned together. True by default.
:param shell: command line used to launch script interpreter. By
default it executes Bash with -eux options enabled. This means that
any command returning non-zero exist status or any any undefined
variable would interrupt script execution with an error and every
command executed by the script is going to be traced to STDERR.
:returns output written by script to STDOUT.
:raises tempest.lib.exceptions.SSHTimeout: in case it fails to connect
to remote server or it fails to open a channel.
:raises tempest.lib.exceptions.SSHExecCommandFailed: in case command
script exits with non zero exit status.
"""
channel = self.open_session()
with channel:
# Combine STOUT and STDERR to have to handle with only one stream
channel.set_combine_stderr(combine_stderr)
# Set default environment
channel.update_environment({
# Language and encoding
'LC_ALL': os.environ.get('LC_ALL') or self.default_ssh_lang,
'LANG': os.environ.get('LANG') or self.default_ssh_lang
})
if become_root:
shell = 'sudo ' + shell
# Spawn a Bash
channel.exec_command(shell)
lines_iterator = iter(script.splitlines())
output_data = b''
error_data = b''
while not channel.exit_status_ready():
# Drain incoming data buffers
while channel.recv_ready():
output_data += channel.recv(self.buf_size)
while channel.recv_stderr_ready():
error_data += channel.recv_stderr(self.buf_size)
if channel.send_ready():
try:
line = next(lines_iterator)
except StopIteration:
# Finalize Bash script execution
channel.shutdown_write()
else:
# Send script to Bash STDIN line by line
channel.send((line + '\n').encode('utf-8'))
else:
time.sleep(.1)
# Get exit status and drain incoming data buffers
exit_status = channel.recv_exit_status()
while channel.recv_ready():
output_data += channel.recv(self.buf_size)
while channel.recv_stderr_ready():
error_data += channel.recv_stderr(self.buf_size)
if exit_status != 0:
raise exceptions.SSHExecCommandFailed(
command='bash', exit_status=exit_status,
stderr=error_data.decode('utf-8'),
stdout=output_data.decode('utf-8'))
return output_data.decode('utf-8')

View File

@ -10,6 +10,7 @@ netaddr>=0.7.18 # BSD
oslo.log>=3.36.0 # Apache-2.0
oslo.serialization!=2.19.1,>=2.18.0 # Apache-2.0
oslo.utils>=3.33.0 # Apache-2.0
paramiko>=2.0.0 # LGPLv2.1+
six>=1.10.0 # MIT
tempest>=17.1.0 # Apache-2.0
ddt>=1.0.1 # MIT