# All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); you may # not use this file except in compliance with the License. You may obtain # a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # 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 CONF = config.CONF 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 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) # 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')