blob: 96f0ef9c2416dd7538884dac64c8838694285613 [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
136 # attribute used to keep reference to opened client connection
137 _client = None
138
139 def connect(self, *args, **kwargs):
140 """Creates paramiko.SSHClient and connect it to remote SSH server
141
142 In case this method is called more times it returns the same client
143 and no new SSH connection is created until close method is called.
144
145 :returns: paramiko.Client connected to remote server.
146
147 :raises tempest.lib.exceptions.SSHTimeout: in case it fails to connect
148 to remote server.
149 """
150 client = self._client
151 if client is None:
152 client = super(Client, self)._get_ssh_connection(
153 *args, **kwargs)
154 self._client = client
155
156 return client
157
158 # This overrides superclass protected method to make sure exec_command
159 # method is going to reuse the same SSH client and connection if called
160 # more times
161 _get_ssh_connection = connect
162
Federico Ressi498a7f42018-10-22 17:44:11 +0200163 # This overrides superclass test_connection_auth method forbidding it to
164 # close connection
165 test_connection_auth = connect
166
Federico Ressi6f0644e2018-07-06 10:05:32 +0200167 def close(self):
Brian Haleyaee61ac2018-10-09 20:00:27 -0400168 """Closes connection to SSH server and cleanup resources."""
Federico Ressi6f0644e2018-07-06 10:05:32 +0200169 client = self._client
170 if client is not None:
171 client.close()
172 self._client = None
173
Federico Ressi498a7f42018-10-22 17:44:11 +0200174 def __exit__(self, _exception_type, _exception_value, _traceback):
175 self.close()
176
Federico Ressi6f0644e2018-07-06 10:05:32 +0200177 def open_session(self):
178 """Gets connection to SSH server and open a new paramiko.Channel
179
180 :returns: new paramiko.Channel
181 """
182
183 client = self.connect()
184
185 try:
186 return client.get_transport().open_session()
187 except paramiko.SSHException:
188 # the request is rejected, the session ends prematurely or
189 # there is a timeout opening a channel
190 LOG.exception("Unable to open SSH session")
191 raise exceptions.SSHTimeout(host=self.host,
192 user=self.username,
193 password=self.password)
194
Rodolfo Alonso Hernandezaa65dfb2019-09-18 11:30:04 +0000195 @tenacity.retry(
196 stop=tenacity.stop_after_attempt(10),
197 wait=tenacity.wait_fixed(1),
198 retry=tenacity.retry_if_exception_type(RETRY_EXCEPTIONS),
199 reraise=True)
Federico Ressi0e04f8f2018-10-24 12:19:05 +0200200 def exec_command(self, cmd, encoding="utf-8", timeout=None):
201 if timeout:
202 original_timeout = self.timeout
203 self.timeout = timeout
204 try:
205 return super(Client, self).exec_command(cmd=cmd, encoding=encoding)
206 finally:
207 if timeout:
208 self.timeout = original_timeout
209
Federico Ressi498a7f42018-10-22 17:44:11 +0200210 def execute_script(self, script, become_root=False, combine_stderr=False,
211 shell='sh -eux', timeout=None, **params):
Federico Ressi6f0644e2018-07-06 10:05:32 +0200212 """Connect to remote machine and executes script.
213
214 Implementation note: it passes script lines to shell interpreter via
215 STDIN. Therefore script line number could be not available to some
216 script interpreters for debugging porposes.
217
218 :param script: script lines to be executed.
219
220 :param become_root: executes interpreter as root with sudo.
221
222 :param combine_stderr (bool): whenever to redirect STDERR to STDOUT so
223 that output from both streams are returned together. True by default.
224
225 :param shell: command line used to launch script interpreter. By
226 default it executes Bash with -eux options enabled. This means that
227 any command returning non-zero exist status or any any undefined
228 variable would interrupt script execution with an error and every
229 command executed by the script is going to be traced to STDERR.
230
Federico Ressi498a7f42018-10-22 17:44:11 +0200231 :param timeout: time in seconds to wait before brutally aborting
232 script execution.
233
234 :param **params: script parameter values to be assigned at the
235 beginning of the script.
236
Federico Ressi6f0644e2018-07-06 10:05:32 +0200237 :returns output written by script to STDOUT.
238
239 :raises tempest.lib.exceptions.SSHTimeout: in case it fails to connect
240 to remote server or it fails to open a channel.
241
242 :raises tempest.lib.exceptions.SSHExecCommandFailed: in case command
Federico Ressi498a7f42018-10-22 17:44:11 +0200243 script exits with non zero exit status or times out.
Federico Ressi6f0644e2018-07-06 10:05:32 +0200244 """
245
Federico Ressi498a7f42018-10-22 17:44:11 +0200246 if params:
247 # Append script parameters at the beginning of the script
248 header = ''.join(sorted(["{!s}={!s}\n".format(k, v)
249 for k, v in params.items()]))
250 script = header + '\n' + script
251
252 timeout = timeout or self.timeout
253 end_of_time = time.time() + timeout
254 output_data = b''
255 error_data = b''
256 exit_status = None
257
Federico Ressi6f0644e2018-07-06 10:05:32 +0200258 channel = self.open_session()
259 with channel:
260
261 # Combine STOUT and STDERR to have to handle with only one stream
262 channel.set_combine_stderr(combine_stderr)
263
Federico Ressi498a7f42018-10-22 17:44:11 +0200264 # Update local environment
265 lang, encoding = locale.getlocale()
266 if not lang:
267 lang, encoding = locale.getdefaultlocale()
268 _locale = '.'.join([lang, encoding])
269 channel.update_environment({'LC_ALL': _locale,
270 'LANG': _locale})
Federico Ressi6f0644e2018-07-06 10:05:32 +0200271
272 if become_root:
273 shell = 'sudo ' + shell
274 # Spawn a Bash
275 channel.exec_command(shell)
276
Federico Ressi498a7f42018-10-22 17:44:11 +0200277 end_of_script = False
Federico Ressi6f0644e2018-07-06 10:05:32 +0200278 lines_iterator = iter(script.splitlines())
Federico Ressi498a7f42018-10-22 17:44:11 +0200279 while (not channel.exit_status_ready() and
280 time.time() < end_of_time):
Federico Ressi6f0644e2018-07-06 10:05:32 +0200281 # Drain incoming data buffers
282 while channel.recv_ready():
283 output_data += channel.recv(self.buf_size)
284 while channel.recv_stderr_ready():
285 error_data += channel.recv_stderr(self.buf_size)
286
Federico Ressi498a7f42018-10-22 17:44:11 +0200287 if not end_of_script and channel.send_ready():
Federico Ressi6f0644e2018-07-06 10:05:32 +0200288 try:
289 line = next(lines_iterator)
290 except StopIteration:
291 # Finalize Bash script execution
292 channel.shutdown_write()
Federico Ressi498a7f42018-10-22 17:44:11 +0200293 end_of_script = True
Federico Ressi6f0644e2018-07-06 10:05:32 +0200294 else:
295 # Send script to Bash STDIN line by line
Federico Ressi498a7f42018-10-22 17:44:11 +0200296 channel.send((line + '\n').encode(encoding))
297 continue
298
299 time.sleep(.1)
Federico Ressi6f0644e2018-07-06 10:05:32 +0200300
301 # Get exit status and drain incoming data buffers
Federico Ressi498a7f42018-10-22 17:44:11 +0200302 if channel.exit_status_ready():
303 exit_status = channel.recv_exit_status()
Federico Ressi6f0644e2018-07-06 10:05:32 +0200304 while channel.recv_ready():
305 output_data += channel.recv(self.buf_size)
306 while channel.recv_stderr_ready():
307 error_data += channel.recv_stderr(self.buf_size)
308
Federico Ressi498a7f42018-10-22 17:44:11 +0200309 stdout = _buffer_to_string(output_data, encoding)
310 if exit_status == 0:
311 return stdout
Federico Ressi6f0644e2018-07-06 10:05:32 +0200312
Federico Ressi498a7f42018-10-22 17:44:11 +0200313 stderr = _buffer_to_string(error_data, encoding)
314 if exit_status is None:
315 raise exc.SSHScriptTimeoutExpired(
Federico Ressi0e04f8f2018-10-24 12:19:05 +0200316 command=shell, host=self.host, script=script, stderr=stderr,
317 stdout=stdout, timeout=timeout)
Federico Ressi498a7f42018-10-22 17:44:11 +0200318 else:
319 raise exc.SSHScriptFailed(
Federico Ressi0e04f8f2018-10-24 12:19:05 +0200320 command=shell, host=self.host, script=script, stderr=stderr,
321 stdout=stdout, exit_status=exit_status)
Federico Ressi498a7f42018-10-22 17:44:11 +0200322
323
324def _buffer_to_string(data_buffer, encoding):
325 return data_buffer.decode(encoding).replace("\r\n", "\n").replace(
326 "\r", "\n")