Merge "Reuse SSH connections for executing multiple commands."
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