Allow to connect to SSH server using an intermediate SSH server

For debugging purposes it could be handy to execute tests
on your workstation and connect test instances via SSH passing
throw an intermediate SSH server.

This allow to configure in tempest.conf an intermediate
SSH client connection to be used from tests to create SSH
connections to VMs.

Example of configuration in tempest.conf:

  [neutron_plugin_options]
  ssh_proxy_jump_host = some.ssh.server
  ssh_proxy_jump_username = root
  # ssh_proxy_jump_password = # better using keys
  proxy_jump_keyfile = ~/.ssh/id_rsa
  proxy_jump_port = 22

Change-Id: Icae73c2cddbdcd8da2b4cdb07a7027791642c6a8
diff --git a/neutron_tempest_plugin/common/ssh.py b/neutron_tempest_plugin/common/ssh.py
index b919b65..99f731c 100644
--- a/neutron_tempest_plugin/common/ssh.py
+++ b/neutron_tempest_plugin/common/ssh.py
@@ -12,13 +12,103 @@
 #    License for the specific language governing permissions and limitations
 #    under the License.
 
+import os
+
+from oslo_log import log
 from tempest.lib.common import ssh
 
 from neutron_tempest_plugin import config
 
 
+CONF = config.CONF
+LOG = log.getLogger(__name__)
+
+
 class Client(ssh.Client):
-    def __init__(self, *args, **kwargs):
-        if 'timeout' not in kwargs:
-            kwargs['timeout'] = config.CONF.validation.ssh_timeout
-        super(Client, self).__init__(*args, **kwargs)
+
+    timeout = CONF.validation.ssh_timeout
+
+    proxy_jump_host = CONF.neutron_plugin_options.ssh_proxy_jump_host
+    proxy_jump_username = CONF.neutron_plugin_options.ssh_proxy_jump_username
+    proxy_jump_password = CONF.neutron_plugin_options.ssh_proxy_jump_password
+    proxy_jump_keyfile = CONF.neutron_plugin_options.ssh_proxy_jump_keyfile
+    proxy_jump_port = CONF.neutron_plugin_options.ssh_proxy_jump_port
+
+    def __init__(self, host, username, password=None, timeout=None, pkey=None,
+                 channel_timeout=10, look_for_keys=False, key_filename=None,
+                 port=22, proxy_client=None):
+
+        timeout = timeout or self.timeout
+
+        if self.proxy_jump_host:
+            # Perform all SSH connections passing through configured SSH server
+            proxy_client = proxy_client or self.create_proxy_client(
+                timeout=timeout, channel_timeout=channel_timeout)
+
+        super(Client, self).__init__(
+            host=host, username=username, password=password, timeout=timeout,
+            pkey=pkey, channel_timeout=channel_timeout,
+            look_for_keys=look_for_keys, key_filename=key_filename, port=port,
+            proxy_client=proxy_client)
+
+    @classmethod
+    def create_proxy_client(cls, look_for_keys=True, **kwargs):
+        host = cls.proxy_jump_host
+        if not host:
+            # proxy_jump_host string cannot be empty or None
+            raise ValueError(
+                "'proxy_jump_host' configuration option is empty.")
+
+        # Let accept an empty string as a synonymous of default value on below
+        # options
+        password = cls.proxy_jump_password or None
+        key_file = cls.proxy_jump_keyfile or None
+        username = cls.proxy_jump_username
+
+        # Port must be a positive integer
+        port = cls.proxy_jump_port
+        if port <= 0 or port > 65535:
+            raise ValueError(
+                "Invalid value for 'proxy_jump_port' configuration option: "
+                "{!r}".format(port))
+
+        login = "{username}@{host}:{port}".format(username=username, host=host,
+                                                  port=port)
+
+        if key_file:
+            # expand ~ character with user HOME directory
+            key_file = os.path.expanduser(key_file)
+            if os.path.isfile(key_file):
+                LOG.debug("Going to create SSH connection to %r using key "
+                          "file: %s", login, key_file)
+
+            else:
+                # This message could help the user to identify a
+                # mis-configuration in tempest.conf
+                raise ValueError(
+                    "Cannot find file specified as 'proxy_jump_keyfile' "
+                    "option: {!r}".format(key_file))
+
+        elif password:
+            LOG.debug("Going to create SSH connection to %r using password.",
+                      login)
+
+        elif look_for_keys:
+            # This message could help the user to identify a mis-configuration
+            # in tempest.conf
+            LOG.info("Both 'proxy_jump_password' and 'proxy_jump_keyfile' "
+                     "options are empty. Going to create SSH connection to %r "
+                     "looking for key file location into %r directory.",
+                     login, os.path.expanduser('~/.ssh'))
+        else:
+            # An user that forces look_for_keys=False should really know what
+            # he really wants
+            LOG.warning("No authentication method provided to create an SSH "
+                        "connection to %r. If it fails, then please "
+                        "set 'proxy_jump_keyfile' to provide a valid SSH key "
+                        "file.", login)
+
+        return ssh.Client(
+            host=host, username=username, password=password,
+            look_for_keys=look_for_keys, key_filename=key_file,
+            port=port, proxy_client=None, **kwargs)
diff --git a/neutron_tempest_plugin/config.py b/neutron_tempest_plugin/config.py
index fc07e81..e15748d 100644
--- a/neutron_tempest_plugin/config.py
+++ b/neutron_tempest_plugin/config.py
@@ -56,7 +56,25 @@
                     '"provider:network_type":<TYPE> - string '
                     '"mtu":<MTU> - integer '
                     '"cidr"<SUBNET/MASK> - string '
-                    '"provider:segmentation_id":<VLAN_ID> - integer')
+                    '"provider:segmentation_id":<VLAN_ID> - integer'),
+
+    # Option for feature to connect via SSH to VMs using an intermediate SSH
+    # server
+    cfg.StrOpt('ssh_proxy_jump_host',
+               default=None,
+               help='Proxy jump host used to connect via SSH to VMs..'),
+    cfg.StrOpt('ssh_proxy_jump_username',
+               default='root',
+               help='User name used to connect to "ssh_proxy_jump_host".'),
+    cfg.StrOpt('ssh_proxy_jump_password',
+               default=None,
+               help='Password used to connect to "ssh_proxy_jump_host".'),
+    cfg.StrOpt('ssh_proxy_jump_keyfile',
+               default=None,
+               help='Keyfile used to connect to "ssh_proxy_jump_host".'),
+    cfg.IntOpt('ssh_proxy_jump_port',
+               default=22,
+               help='Port used to connect to "ssh_proxy_jump_host".'),
 ]
 
 # TODO(amuller): Redo configuration options registration as part of the planned