ssh: Add proxy support

Add ssh-over-ssh support by making ssh.Client take another
Client instance, which is used to provide a transport.

A use case: Iaa7121ced07f7877292e4ff15926bf02b5e7aaa1

Change-Id: Id3269696f1aac0e4cddab8579ef49798533aba23
diff --git a/tempest/lib/common/ssh.py b/tempest/lib/common/ssh.py
index 4226cd6..5e65bee 100644
--- a/tempest/lib/common/ssh.py
+++ b/tempest/lib/common/ssh.py
@@ -37,7 +37,30 @@
 
     def __init__(self, host, username, password=None, timeout=300, pkey=None,
                  channel_timeout=10, look_for_keys=False, key_filename=None,
-                 port=22):
+                 port=22, proxy_client=None):
+        """SSH client.
+
+        Many of parameters are just passed to the underlying implementation
+        as it is.  See the paramiko documentation for more details.
+        http://docs.paramiko.org/en/2.1/api/client.html#paramiko.client.SSHClient.connect
+
+        :param host: Host to login.
+        :param username: SSH username.
+        :param password: SSH password, or a password to unlock private key.
+        :param timeout: Timeout in seconds, including retries.
+            Default is 300 seconds.
+        :param pkey: Private key.
+        :param channel_timeout: Channel timeout in seconds, passed to the
+            paramiko.  Default is 10 seconds.
+        :param look_for_keys: Whether or not to search for private keys
+            in ``~/.ssh``.  Default is False.
+        :param key_filename: Filename for private key to use.
+        :param port: SSH port number.
+        :param proxy_client: Another SSH client to provide a transport
+            for ssh-over-ssh.  The default is None, which means
+            not to use ssh-over-ssh.
+        :type proxy_client: ``tempest.lib.common.ssh.Client`` object
+        """
         self.host = host
         self.username = username
         self.port = port
@@ -51,6 +74,8 @@
         self.timeout = int(timeout)
         self.channel_timeout = float(channel_timeout)
         self.buf_size = 1024
+        self.proxy_client = proxy_client
+        self._proxy_conn = None
 
     def _get_ssh_connection(self, sleep=1.5, backoff=1):
         """Returns an ssh connection to the specified host."""
@@ -59,6 +84,10 @@
         ssh.set_missing_host_key_policy(
             paramiko.AutoAddPolicy())
         _start_time = time.time()
+        if self.proxy_client is not None:
+            proxy_chan = self._get_proxy_channel()
+        else:
+            proxy_chan = None
         if self.pkey is not None:
             LOG.info("Creating ssh connection to '%s:%d' as '%s'"
                      " with public key authentication",
@@ -74,7 +103,8 @@
                             password=self.password,
                             look_for_keys=self.look_for_keys,
                             key_filename=self.key_filename,
-                            timeout=self.channel_timeout, pkey=self.pkey)
+                            timeout=self.channel_timeout, pkey=self.pkey,
+                            sock=proxy_chan)
                 LOG.info("ssh connection to %s@%s successfully created",
                          self.username, self.host)
                 return ssh
@@ -175,3 +205,14 @@
         """Raises an exception when we can not connect to server via ssh."""
         connection = self._get_ssh_connection()
         connection.close()
+
+    def _get_proxy_channel(self):
+        conn = self.proxy_client._get_ssh_connection()
+        # Keep a reference to avoid g/c
+        # https://github.com/paramiko/paramiko/issues/440
+        self._proxy_conn = conn
+        transport = conn.get_transport()
+        chan = transport.open_session()
+        cmd = 'nc %s %s' % (self.host, self.port)
+        chan.exec_command(cmd)
+        return chan
diff --git a/tempest/tests/lib/test_ssh.py b/tempest/tests/lib/test_ssh.py
index 8a0a84c..a16da1c 100644
--- a/tempest/tests/lib/test_ssh.py
+++ b/tempest/tests/lib/test_ssh.py
@@ -75,7 +75,54 @@
             key_filename=None,
             look_for_keys=False,
             timeout=10.0,
-            password=None
+            password=None,
+            sock=None
+        )]
+        self.assertEqual(expected_connect, client_mock.connect.mock_calls)
+        self.assertEqual(0, s_mock.call_count)
+
+    def test_get_ssh_connection_over_ssh(self):
+        c_mock, aa_mock, client_mock = self._set_ssh_connection_mocks()
+        proxy_client_mock = mock.MagicMock()
+        proxy_client_mock.connect.return_value = True
+        s_mock = self.patch('time.sleep')
+
+        c_mock.side_effect = [client_mock, proxy_client_mock]
+        aa_mock.return_value = mock.sentinel.aa
+
+        proxy_client = ssh.Client('proxy-host', 'proxy-user', timeout=2)
+        client = ssh.Client('localhost', 'root', timeout=2,
+                            proxy_client=proxy_client)
+        client._get_ssh_connection(sleep=1)
+
+        aa_mock.assert_has_calls([mock.call(), mock.call()])
+        proxy_client_mock.set_missing_host_key_policy.assert_called_once_with(
+            mock.sentinel.aa)
+        proxy_expected_connect = [mock.call(
+            'proxy-host',
+            port=22,
+            username='proxy-user',
+            pkey=None,
+            key_filename=None,
+            look_for_keys=False,
+            timeout=10.0,
+            password=None,
+            sock=None
+        )]
+        self.assertEqual(proxy_expected_connect,
+                         proxy_client_mock.connect.mock_calls)
+        client_mock.set_missing_host_key_policy.assert_called_once_with(
+            mock.sentinel.aa)
+        expected_connect = [mock.call(
+            'localhost',
+            port=22,
+            username='root',
+            pkey=None,
+            key_filename=None,
+            look_for_keys=False,
+            timeout=10.0,
+            password=None,
+            sock=proxy_client_mock.get_transport().open_session()
         )]
         self.assertEqual(expected_connect, client_mock.connect.mock_calls)
         self.assertEqual(0, s_mock.call_count)