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
diff --git a/neutron_tempest_plugin/common/ssh.py b/neutron_tempest_plugin/common/ssh.py
index 99f731c..9812f4c 100644
--- a/neutron_tempest_plugin/common/ssh.py
+++ b/neutron_tempest_plugin/common/ssh.py
@@ -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 @@
 
 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 @@
             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')
diff --git a/requirements.txt b/requirements.txt
index e48daf7..dc77e63 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -10,6 +10,7 @@
 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