blob: fa731d80b72e2145d7211102bdac89733698aa68 [file] [log] [blame]
Jakub Libosvar7c58cb22017-05-03 09:00:14 +00001# All Rights Reserved.
2#
3# Licensed under the Apache License, Version 2.0 (the "License"); you may
4# not use this file except in compliance with the License. You may obtain
5# a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
11# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12# License for the specific language governing permissions and limitations
13# under the License.
14
Federico Ressi498a7f42018-10-22 17:44:11 +020015import locale
Federico Ressie9c89bf2018-04-19 13:02:33 +020016import os
Rodolfo Alonso Hernandezaa65dfb2019-09-18 11:30:04 +000017import socket
Federico Ressi6f0644e2018-07-06 10:05:32 +020018import time
Federico Ressie9c89bf2018-04-19 13:02:33 +020019
20from oslo_log import log
Federico Ressi6f0644e2018-07-06 10:05:32 +020021import paramiko
Rodolfo Alonso Hernandezaa65dfb2019-09-18 11:30:04 +000022import six
Jakub Libosvar7c58cb22017-05-03 09:00:14 +000023from tempest.lib.common import ssh
Federico Ressi6f0644e2018-07-06 10:05:32 +020024from tempest.lib import exceptions
Rodolfo Alonso Hernandezaa65dfb2019-09-18 11:30:04 +000025import tenacity
Jakub Libosvar7c58cb22017-05-03 09:00:14 +000026
Chandan Kumar667d3d32017-09-22 12:24:06 +053027from neutron_tempest_plugin import config
Federico Ressi498a7f42018-10-22 17:44:11 +020028from neutron_tempest_plugin import exceptions as exc
Jakub Libosvar7c58cb22017-05-03 09:00:14 +000029
30
Federico Ressie9c89bf2018-04-19 13:02:33 +020031CONF = config.CONF
32LOG = log.getLogger(__name__)
33
34
Rodolfo Alonso Hernandezaa65dfb2019-09-18 11:30:04 +000035RETRY_EXCEPTIONS = (exceptions.TimeoutException, paramiko.SSHException,
36 socket.error)
37if six.PY2:
38 # NOTE(ralonsoh): TimeoutError was added in 3.3 and corresponds to
39 # OSError(errno.ETIMEDOUT)
40 RETRY_EXCEPTIONS += (OSError, )
41else:
42 RETRY_EXCEPTIONS += (TimeoutError, )
43
44
Jakub Libosvar7c58cb22017-05-03 09:00:14 +000045class Client(ssh.Client):
Federico Ressie9c89bf2018-04-19 13:02:33 +020046
Federico Ressi6f0644e2018-07-06 10:05:32 +020047 default_ssh_lang = 'en_US.UTF-8'
48
Federico Ressie9c89bf2018-04-19 13:02:33 +020049 timeout = CONF.validation.ssh_timeout
50
51 proxy_jump_host = CONF.neutron_plugin_options.ssh_proxy_jump_host
52 proxy_jump_username = CONF.neutron_plugin_options.ssh_proxy_jump_username
53 proxy_jump_password = CONF.neutron_plugin_options.ssh_proxy_jump_password
54 proxy_jump_keyfile = CONF.neutron_plugin_options.ssh_proxy_jump_keyfile
55 proxy_jump_port = CONF.neutron_plugin_options.ssh_proxy_jump_port
56
57 def __init__(self, host, username, password=None, timeout=None, pkey=None,
58 channel_timeout=10, look_for_keys=False, key_filename=None,
Federico Ressi0e04f8f2018-10-24 12:19:05 +020059 port=22, proxy_client=None, create_proxy_client=True):
Federico Ressie9c89bf2018-04-19 13:02:33 +020060
61 timeout = timeout or self.timeout
62
Federico Ressi0e04f8f2018-10-24 12:19:05 +020063 if not proxy_client and create_proxy_client and self.proxy_jump_host:
Federico Ressie9c89bf2018-04-19 13:02:33 +020064 # Perform all SSH connections passing through configured SSH server
Federico Ressi0e04f8f2018-10-24 12:19:05 +020065 proxy_client = self.create_proxy_client(
Federico Ressie9c89bf2018-04-19 13:02:33 +020066 timeout=timeout, channel_timeout=channel_timeout)
67
68 super(Client, self).__init__(
69 host=host, username=username, password=password, timeout=timeout,
70 pkey=pkey, channel_timeout=channel_timeout,
71 look_for_keys=look_for_keys, key_filename=key_filename, port=port,
72 proxy_client=proxy_client)
73
74 @classmethod
75 def create_proxy_client(cls, look_for_keys=True, **kwargs):
76 host = cls.proxy_jump_host
77 if not host:
78 # proxy_jump_host string cannot be empty or None
79 raise ValueError(
80 "'proxy_jump_host' configuration option is empty.")
81
82 # Let accept an empty string as a synonymous of default value on below
83 # options
84 password = cls.proxy_jump_password or None
85 key_file = cls.proxy_jump_keyfile or None
86 username = cls.proxy_jump_username
87
88 # Port must be a positive integer
89 port = cls.proxy_jump_port
90 if port <= 0 or port > 65535:
91 raise ValueError(
92 "Invalid value for 'proxy_jump_port' configuration option: "
93 "{!r}".format(port))
94
95 login = "{username}@{host}:{port}".format(username=username, host=host,
96 port=port)
97
98 if key_file:
99 # expand ~ character with user HOME directory
100 key_file = os.path.expanduser(key_file)
101 if os.path.isfile(key_file):
102 LOG.debug("Going to create SSH connection to %r using key "
103 "file: %s", login, key_file)
104
105 else:
106 # This message could help the user to identify a
107 # mis-configuration in tempest.conf
108 raise ValueError(
109 "Cannot find file specified as 'proxy_jump_keyfile' "
110 "option: {!r}".format(key_file))
111
112 elif password:
113 LOG.debug("Going to create SSH connection to %r using password.",
114 login)
115
116 elif look_for_keys:
117 # This message could help the user to identify a mis-configuration
118 # in tempest.conf
119 LOG.info("Both 'proxy_jump_password' and 'proxy_jump_keyfile' "
120 "options are empty. Going to create SSH connection to %r "
121 "looking for key file location into %r directory.",
122 login, os.path.expanduser('~/.ssh'))
123 else:
124 # An user that forces look_for_keys=False should really know what
125 # he really wants
126 LOG.warning("No authentication method provided to create an SSH "
127 "connection to %r. If it fails, then please "
128 "set 'proxy_jump_keyfile' to provide a valid SSH key "
129 "file.", login)
130
Federico Ressi0e04f8f2018-10-24 12:19:05 +0200131 return Client(
Federico Ressie9c89bf2018-04-19 13:02:33 +0200132 host=host, username=username, password=password,
133 look_for_keys=look_for_keys, key_filename=key_file,
Federico Ressi0e04f8f2018-10-24 12:19:05 +0200134 port=port, create_proxy_client=False, **kwargs)
Federico Ressi6f0644e2018-07-06 10:05:32 +0200135
Federico Ressi6f0644e2018-07-06 10:05:32 +0200136 def connect(self, *args, **kwargs):
137 """Creates paramiko.SSHClient and connect it to remote SSH server
138
Federico Ressi6f0644e2018-07-06 10:05:32 +0200139 :returns: paramiko.Client connected to remote server.
140
141 :raises tempest.lib.exceptions.SSHTimeout: in case it fails to connect
142 to remote server.
143 """
Dr. Jens Harbott73e15402020-01-03 12:06:12 +0000144 return super(Client, self)._get_ssh_connection(*args, **kwargs)
Federico Ressi6f0644e2018-07-06 10:05:32 +0200145
Federico Ressi498a7f42018-10-22 17:44:11 +0200146 # This overrides superclass test_connection_auth method forbidding it to
147 # close connection
148 test_connection_auth = connect
149
Federico Ressi6f0644e2018-07-06 10:05:32 +0200150 def open_session(self):
151 """Gets connection to SSH server and open a new paramiko.Channel
152
153 :returns: new paramiko.Channel
154 """
155
156 client = self.connect()
157
158 try:
159 return client.get_transport().open_session()
160 except paramiko.SSHException:
161 # the request is rejected, the session ends prematurely or
162 # there is a timeout opening a channel
163 LOG.exception("Unable to open SSH session")
164 raise exceptions.SSHTimeout(host=self.host,
165 user=self.username,
166 password=self.password)
167
Rodolfo Alonso Hernandezaa65dfb2019-09-18 11:30:04 +0000168 @tenacity.retry(
169 stop=tenacity.stop_after_attempt(10),
170 wait=tenacity.wait_fixed(1),
171 retry=tenacity.retry_if_exception_type(RETRY_EXCEPTIONS),
172 reraise=True)
Federico Ressi0e04f8f2018-10-24 12:19:05 +0200173 def exec_command(self, cmd, encoding="utf-8", timeout=None):
174 if timeout:
175 original_timeout = self.timeout
176 self.timeout = timeout
177 try:
178 return super(Client, self).exec_command(cmd=cmd, encoding=encoding)
179 finally:
180 if timeout:
181 self.timeout = original_timeout
182
Federico Ressi498a7f42018-10-22 17:44:11 +0200183 def execute_script(self, script, become_root=False, combine_stderr=False,
184 shell='sh -eux', timeout=None, **params):
Federico Ressi6f0644e2018-07-06 10:05:32 +0200185 """Connect to remote machine and executes script.
186
187 Implementation note: it passes script lines to shell interpreter via
188 STDIN. Therefore script line number could be not available to some
189 script interpreters for debugging porposes.
190
191 :param script: script lines to be executed.
192
193 :param become_root: executes interpreter as root with sudo.
194
195 :param combine_stderr (bool): whenever to redirect STDERR to STDOUT so
196 that output from both streams are returned together. True by default.
197
198 :param shell: command line used to launch script interpreter. By
199 default it executes Bash with -eux options enabled. This means that
200 any command returning non-zero exist status or any any undefined
201 variable would interrupt script execution with an error and every
202 command executed by the script is going to be traced to STDERR.
203
Federico Ressi498a7f42018-10-22 17:44:11 +0200204 :param timeout: time in seconds to wait before brutally aborting
205 script execution.
206
207 :param **params: script parameter values to be assigned at the
208 beginning of the script.
209
Federico Ressi6f0644e2018-07-06 10:05:32 +0200210 :returns output written by script to STDOUT.
211
212 :raises tempest.lib.exceptions.SSHTimeout: in case it fails to connect
213 to remote server or it fails to open a channel.
214
215 :raises tempest.lib.exceptions.SSHExecCommandFailed: in case command
Federico Ressi498a7f42018-10-22 17:44:11 +0200216 script exits with non zero exit status or times out.
Federico Ressi6f0644e2018-07-06 10:05:32 +0200217 """
218
Federico Ressi498a7f42018-10-22 17:44:11 +0200219 if params:
220 # Append script parameters at the beginning of the script
221 header = ''.join(sorted(["{!s}={!s}\n".format(k, v)
222 for k, v in params.items()]))
223 script = header + '\n' + script
224
225 timeout = timeout or self.timeout
226 end_of_time = time.time() + timeout
227 output_data = b''
228 error_data = b''
229 exit_status = None
230
Federico Ressi6f0644e2018-07-06 10:05:32 +0200231 channel = self.open_session()
232 with channel:
233
234 # Combine STOUT and STDERR to have to handle with only one stream
235 channel.set_combine_stderr(combine_stderr)
236
Federico Ressi498a7f42018-10-22 17:44:11 +0200237 # Update local environment
238 lang, encoding = locale.getlocale()
239 if not lang:
240 lang, encoding = locale.getdefaultlocale()
241 _locale = '.'.join([lang, encoding])
242 channel.update_environment({'LC_ALL': _locale,
243 'LANG': _locale})
Federico Ressi6f0644e2018-07-06 10:05:32 +0200244
245 if become_root:
246 shell = 'sudo ' + shell
247 # Spawn a Bash
248 channel.exec_command(shell)
249
Federico Ressi498a7f42018-10-22 17:44:11 +0200250 end_of_script = False
Federico Ressi6f0644e2018-07-06 10:05:32 +0200251 lines_iterator = iter(script.splitlines())
Federico Ressi498a7f42018-10-22 17:44:11 +0200252 while (not channel.exit_status_ready() and
253 time.time() < end_of_time):
Federico Ressi6f0644e2018-07-06 10:05:32 +0200254 # Drain incoming data buffers
255 while channel.recv_ready():
256 output_data += channel.recv(self.buf_size)
257 while channel.recv_stderr_ready():
258 error_data += channel.recv_stderr(self.buf_size)
259
Federico Ressi498a7f42018-10-22 17:44:11 +0200260 if not end_of_script and channel.send_ready():
Federico Ressi6f0644e2018-07-06 10:05:32 +0200261 try:
262 line = next(lines_iterator)
263 except StopIteration:
264 # Finalize Bash script execution
265 channel.shutdown_write()
Federico Ressi498a7f42018-10-22 17:44:11 +0200266 end_of_script = True
Federico Ressi6f0644e2018-07-06 10:05:32 +0200267 else:
268 # Send script to Bash STDIN line by line
Federico Ressi498a7f42018-10-22 17:44:11 +0200269 channel.send((line + '\n').encode(encoding))
270 continue
271
272 time.sleep(.1)
Federico Ressi6f0644e2018-07-06 10:05:32 +0200273
274 # Get exit status and drain incoming data buffers
Federico Ressi498a7f42018-10-22 17:44:11 +0200275 if channel.exit_status_ready():
276 exit_status = channel.recv_exit_status()
Federico Ressi6f0644e2018-07-06 10:05:32 +0200277 while channel.recv_ready():
278 output_data += channel.recv(self.buf_size)
279 while channel.recv_stderr_ready():
280 error_data += channel.recv_stderr(self.buf_size)
281
Federico Ressi498a7f42018-10-22 17:44:11 +0200282 stdout = _buffer_to_string(output_data, encoding)
283 if exit_status == 0:
284 return stdout
Federico Ressi6f0644e2018-07-06 10:05:32 +0200285
Federico Ressi498a7f42018-10-22 17:44:11 +0200286 stderr = _buffer_to_string(error_data, encoding)
287 if exit_status is None:
288 raise exc.SSHScriptTimeoutExpired(
Federico Ressi0e04f8f2018-10-24 12:19:05 +0200289 command=shell, host=self.host, script=script, stderr=stderr,
290 stdout=stdout, timeout=timeout)
Federico Ressi498a7f42018-10-22 17:44:11 +0200291 else:
292 raise exc.SSHScriptFailed(
Federico Ressi0e04f8f2018-10-24 12:19:05 +0200293 command=shell, host=self.host, script=script, stderr=stderr,
294 stdout=stdout, exit_status=exit_status)
Federico Ressi498a7f42018-10-22 17:44:11 +0200295
296
297def _buffer_to_string(data_buffer, encoding):
298 return data_buffer.decode(encoding).replace("\r\n", "\n").replace(
299 "\r", "\n")