f2e985e8fd
Enable sphinx to generate documentation from docstrings by running 'tox -e docs'. Change-Id: I5996e5f07493f69f14172b4bb0535852e89d5456
301 lines
12 KiB
Python
301 lines
12 KiB
Python
# 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 locale
|
|
import os
|
|
import socket
|
|
import time
|
|
|
|
from oslo_log import log
|
|
import paramiko
|
|
from tempest.lib.common import ssh
|
|
from tempest.lib import exceptions
|
|
import tenacity
|
|
|
|
from neutron_tempest_plugin import config
|
|
from neutron_tempest_plugin import exceptions as exc
|
|
|
|
|
|
CONF = config.CONF
|
|
LOG = log.getLogger(__name__)
|
|
|
|
|
|
RETRY_EXCEPTIONS = (exceptions.TimeoutException, paramiko.SSHException,
|
|
socket.error, TimeoutError)
|
|
|
|
|
|
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, create_proxy_client=True):
|
|
|
|
timeout = timeout or self.timeout
|
|
|
|
if not proxy_client and create_proxy_client and self.proxy_jump_host:
|
|
# Perform all SSH connections passing through configured SSH server
|
|
proxy_client = 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,
|
|
ssh_key_type=CONF.validation.ssh_key_type)
|
|
|
|
@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 Client(
|
|
host=host, username=username, password=password,
|
|
look_for_keys=look_for_keys, key_filename=key_file,
|
|
port=port, create_proxy_client=False, **kwargs)
|
|
|
|
def connect(self, *args, **kwargs):
|
|
"""Creates paramiko.SSHClient and connect it to remote SSH server
|
|
|
|
:returns: paramiko.Client connected to remote server.
|
|
|
|
:raises tempest.lib.exceptions.SSHTimeout: in case it fails to connect
|
|
to remote server.
|
|
"""
|
|
return super(Client, self)._get_ssh_connection(*args, **kwargs)
|
|
|
|
# This overrides superclass test_connection_auth method forbidding it to
|
|
# close connection
|
|
test_connection_auth = connect
|
|
|
|
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)
|
|
|
|
@tenacity.retry(
|
|
stop=tenacity.stop_after_attempt(10),
|
|
wait=tenacity.wait_fixed(1),
|
|
retry=tenacity.retry_if_exception_type(RETRY_EXCEPTIONS),
|
|
reraise=True)
|
|
def exec_command(self, cmd, encoding="utf-8", timeout=None):
|
|
if timeout:
|
|
original_timeout = self.timeout
|
|
self.timeout = timeout
|
|
try:
|
|
return super(Client, self).exec_command(cmd=cmd, encoding=encoding)
|
|
finally:
|
|
if timeout:
|
|
self.timeout = original_timeout
|
|
|
|
def execute_script(self, script, become_root=False, combine_stderr=False,
|
|
shell='sh -eux', timeout=None, **params):
|
|
"""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.
|
|
|
|
:param timeout: time in seconds to wait before brutally aborting
|
|
script execution.
|
|
|
|
:param **params: script parameter values to be assigned at the
|
|
beginning of the script.
|
|
|
|
: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 or times out.
|
|
"""
|
|
|
|
if params:
|
|
# Append script parameters at the beginning of the script
|
|
header = ''.join(sorted(["{!s}={!s}\n".format(k, v)
|
|
for k, v in params.items()]))
|
|
script = header + '\n' + script
|
|
|
|
timeout = timeout or self.timeout
|
|
end_of_time = time.time() + timeout
|
|
output_data = b''
|
|
error_data = b''
|
|
exit_status = None
|
|
|
|
channel = self.open_session()
|
|
with channel:
|
|
|
|
# Combine STOUT and STDERR to have to handle with only one stream
|
|
channel.set_combine_stderr(combine_stderr)
|
|
|
|
# Update local environment
|
|
lang, encoding = locale.getlocale()
|
|
if not lang:
|
|
lang, encoding = locale.getdefaultlocale()
|
|
_locale = '.'.join([lang, encoding])
|
|
channel.update_environment({'LC_ALL': _locale,
|
|
'LANG': _locale})
|
|
|
|
if become_root:
|
|
shell = 'sudo ' + shell
|
|
# Spawn a Bash
|
|
channel.exec_command(shell)
|
|
|
|
end_of_script = False
|
|
lines_iterator = iter(script.splitlines())
|
|
while (not channel.exit_status_ready() and
|
|
time.time() < end_of_time):
|
|
# 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 not end_of_script and channel.send_ready():
|
|
try:
|
|
line = next(lines_iterator)
|
|
except StopIteration:
|
|
# Finalize Bash script execution
|
|
channel.shutdown_write()
|
|
end_of_script = True
|
|
else:
|
|
# Send script to Bash STDIN line by line
|
|
channel.send((line + '\n').encode(encoding))
|
|
continue
|
|
|
|
time.sleep(.1)
|
|
|
|
# Get exit status and drain incoming data buffers
|
|
if channel.exit_status_ready():
|
|
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)
|
|
|
|
stdout = _buffer_to_string(output_data, encoding)
|
|
if exit_status == 0:
|
|
return stdout
|
|
|
|
stderr = _buffer_to_string(error_data, encoding)
|
|
if exit_status is None:
|
|
raise exc.SSHScriptTimeoutExpired(
|
|
command=shell, host=self.host, script=script, stderr=stderr,
|
|
stdout=stdout, timeout=timeout)
|
|
else:
|
|
raise exc.SSHScriptFailed(
|
|
command=shell, host=self.host, script=script, stderr=stderr,
|
|
stdout=stdout, exit_status=exit_status)
|
|
|
|
def get_hostname(self):
|
|
"""Retrieve the remote machine hostname"""
|
|
try:
|
|
return self.exec_command('hostname')
|
|
except exceptions.SSHExecCommandFailed:
|
|
return self.exec_command('cat /etc/hostname')
|
|
|
|
|
|
def _buffer_to_string(data_buffer, encoding):
|
|
return data_buffer.decode(encoding).replace("\r\n", "\n").replace(
|
|
"\r", "\n")
|