blob: c6204a43a79dba6e733bc859213e82ac4957b601 [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
Jakub Libosvar7c58cb22017-05-03 09:00:14 +000022from tempest.lib.common import ssh
Federico Ressi6f0644e2018-07-06 10:05:32 +020023from tempest.lib import exceptions
Rodolfo Alonso Hernandezaa65dfb2019-09-18 11:30:04 +000024import tenacity
Jakub Libosvar7c58cb22017-05-03 09:00:14 +000025
Chandan Kumar667d3d32017-09-22 12:24:06 +053026from neutron_tempest_plugin import config
Federico Ressi498a7f42018-10-22 17:44:11 +020027from neutron_tempest_plugin import exceptions as exc
Jakub Libosvar7c58cb22017-05-03 09:00:14 +000028
29
Federico Ressie9c89bf2018-04-19 13:02:33 +020030CONF = config.CONF
31LOG = log.getLogger(__name__)
32
33
Rodolfo Alonso Hernandezaa65dfb2019-09-18 11:30:04 +000034RETRY_EXCEPTIONS = (exceptions.TimeoutException, paramiko.SSHException,
wangzihao8e4c6dd2020-11-04 09:30:48 +080035 socket.error, TimeoutError)
Rodolfo Alonso Hernandezaa65dfb2019-09-18 11:30:04 +000036
37
Jakub Libosvar7c58cb22017-05-03 09:00:14 +000038class Client(ssh.Client):
Federico Ressie9c89bf2018-04-19 13:02:33 +020039
Federico Ressi6f0644e2018-07-06 10:05:32 +020040 default_ssh_lang = 'en_US.UTF-8'
41
Federico Ressie9c89bf2018-04-19 13:02:33 +020042 timeout = CONF.validation.ssh_timeout
43
44 proxy_jump_host = CONF.neutron_plugin_options.ssh_proxy_jump_host
45 proxy_jump_username = CONF.neutron_plugin_options.ssh_proxy_jump_username
46 proxy_jump_password = CONF.neutron_plugin_options.ssh_proxy_jump_password
47 proxy_jump_keyfile = CONF.neutron_plugin_options.ssh_proxy_jump_keyfile
48 proxy_jump_port = CONF.neutron_plugin_options.ssh_proxy_jump_port
49
50 def __init__(self, host, username, password=None, timeout=None, pkey=None,
51 channel_timeout=10, look_for_keys=False, key_filename=None,
Federico Ressi0e04f8f2018-10-24 12:19:05 +020052 port=22, proxy_client=None, create_proxy_client=True):
Federico Ressie9c89bf2018-04-19 13:02:33 +020053
54 timeout = timeout or self.timeout
55
Federico Ressi0e04f8f2018-10-24 12:19:05 +020056 if not proxy_client and create_proxy_client and self.proxy_jump_host:
Federico Ressie9c89bf2018-04-19 13:02:33 +020057 # Perform all SSH connections passing through configured SSH server
Federico Ressi0e04f8f2018-10-24 12:19:05 +020058 proxy_client = self.create_proxy_client(
Federico Ressie9c89bf2018-04-19 13:02:33 +020059 timeout=timeout, channel_timeout=channel_timeout)
60
61 super(Client, self).__init__(
62 host=host, username=username, password=password, timeout=timeout,
63 pkey=pkey, channel_timeout=channel_timeout,
64 look_for_keys=look_for_keys, key_filename=key_filename, port=port,
65 proxy_client=proxy_client)
66
67 @classmethod
68 def create_proxy_client(cls, look_for_keys=True, **kwargs):
69 host = cls.proxy_jump_host
70 if not host:
71 # proxy_jump_host string cannot be empty or None
72 raise ValueError(
73 "'proxy_jump_host' configuration option is empty.")
74
75 # Let accept an empty string as a synonymous of default value on below
76 # options
77 password = cls.proxy_jump_password or None
78 key_file = cls.proxy_jump_keyfile or None
79 username = cls.proxy_jump_username
80
81 # Port must be a positive integer
82 port = cls.proxy_jump_port
83 if port <= 0 or port > 65535:
84 raise ValueError(
85 "Invalid value for 'proxy_jump_port' configuration option: "
86 "{!r}".format(port))
87
88 login = "{username}@{host}:{port}".format(username=username, host=host,
89 port=port)
90
91 if key_file:
92 # expand ~ character with user HOME directory
93 key_file = os.path.expanduser(key_file)
94 if os.path.isfile(key_file):
95 LOG.debug("Going to create SSH connection to %r using key "
96 "file: %s", login, key_file)
97
98 else:
99 # This message could help the user to identify a
100 # mis-configuration in tempest.conf
101 raise ValueError(
102 "Cannot find file specified as 'proxy_jump_keyfile' "
103 "option: {!r}".format(key_file))
104
105 elif password:
106 LOG.debug("Going to create SSH connection to %r using password.",
107 login)
108
109 elif look_for_keys:
110 # This message could help the user to identify a mis-configuration
111 # in tempest.conf
112 LOG.info("Both 'proxy_jump_password' and 'proxy_jump_keyfile' "
113 "options are empty. Going to create SSH connection to %r "
114 "looking for key file location into %r directory.",
115 login, os.path.expanduser('~/.ssh'))
116 else:
117 # An user that forces look_for_keys=False should really know what
118 # he really wants
119 LOG.warning("No authentication method provided to create an SSH "
120 "connection to %r. If it fails, then please "
121 "set 'proxy_jump_keyfile' to provide a valid SSH key "
122 "file.", login)
123
Federico Ressi0e04f8f2018-10-24 12:19:05 +0200124 return Client(
Federico Ressie9c89bf2018-04-19 13:02:33 +0200125 host=host, username=username, password=password,
126 look_for_keys=look_for_keys, key_filename=key_file,
Federico Ressi0e04f8f2018-10-24 12:19:05 +0200127 port=port, create_proxy_client=False, **kwargs)
Federico Ressi6f0644e2018-07-06 10:05:32 +0200128
Federico Ressi6f0644e2018-07-06 10:05:32 +0200129 def connect(self, *args, **kwargs):
130 """Creates paramiko.SSHClient and connect it to remote SSH server
131
Federico Ressi6f0644e2018-07-06 10:05:32 +0200132 :returns: paramiko.Client connected to remote server.
133
134 :raises tempest.lib.exceptions.SSHTimeout: in case it fails to connect
135 to remote server.
136 """
Dr. Jens Harbott73e15402020-01-03 12:06:12 +0000137 return super(Client, self)._get_ssh_connection(*args, **kwargs)
Federico Ressi6f0644e2018-07-06 10:05:32 +0200138
Federico Ressi498a7f42018-10-22 17:44:11 +0200139 # This overrides superclass test_connection_auth method forbidding it to
140 # close connection
141 test_connection_auth = connect
142
Federico Ressi6f0644e2018-07-06 10:05:32 +0200143 def open_session(self):
144 """Gets connection to SSH server and open a new paramiko.Channel
145
146 :returns: new paramiko.Channel
147 """
148
149 client = self.connect()
150
151 try:
152 return client.get_transport().open_session()
153 except paramiko.SSHException:
154 # the request is rejected, the session ends prematurely or
155 # there is a timeout opening a channel
156 LOG.exception("Unable to open SSH session")
157 raise exceptions.SSHTimeout(host=self.host,
158 user=self.username,
159 password=self.password)
160
Rodolfo Alonso Hernandezaa65dfb2019-09-18 11:30:04 +0000161 @tenacity.retry(
162 stop=tenacity.stop_after_attempt(10),
163 wait=tenacity.wait_fixed(1),
164 retry=tenacity.retry_if_exception_type(RETRY_EXCEPTIONS),
165 reraise=True)
Federico Ressi0e04f8f2018-10-24 12:19:05 +0200166 def exec_command(self, cmd, encoding="utf-8", timeout=None):
167 if timeout:
168 original_timeout = self.timeout
169 self.timeout = timeout
170 try:
171 return super(Client, self).exec_command(cmd=cmd, encoding=encoding)
172 finally:
173 if timeout:
174 self.timeout = original_timeout
175
Federico Ressi498a7f42018-10-22 17:44:11 +0200176 def execute_script(self, script, become_root=False, combine_stderr=False,
177 shell='sh -eux', timeout=None, **params):
Federico Ressi6f0644e2018-07-06 10:05:32 +0200178 """Connect to remote machine and executes script.
179
180 Implementation note: it passes script lines to shell interpreter via
181 STDIN. Therefore script line number could be not available to some
182 script interpreters for debugging porposes.
183
184 :param script: script lines to be executed.
185
186 :param become_root: executes interpreter as root with sudo.
187
188 :param combine_stderr (bool): whenever to redirect STDERR to STDOUT so
189 that output from both streams are returned together. True by default.
190
191 :param shell: command line used to launch script interpreter. By
192 default it executes Bash with -eux options enabled. This means that
193 any command returning non-zero exist status or any any undefined
194 variable would interrupt script execution with an error and every
195 command executed by the script is going to be traced to STDERR.
196
Federico Ressi498a7f42018-10-22 17:44:11 +0200197 :param timeout: time in seconds to wait before brutally aborting
198 script execution.
199
200 :param **params: script parameter values to be assigned at the
201 beginning of the script.
202
Federico Ressi6f0644e2018-07-06 10:05:32 +0200203 :returns output written by script to STDOUT.
204
205 :raises tempest.lib.exceptions.SSHTimeout: in case it fails to connect
206 to remote server or it fails to open a channel.
207
208 :raises tempest.lib.exceptions.SSHExecCommandFailed: in case command
Federico Ressi498a7f42018-10-22 17:44:11 +0200209 script exits with non zero exit status or times out.
Federico Ressi6f0644e2018-07-06 10:05:32 +0200210 """
211
Federico Ressi498a7f42018-10-22 17:44:11 +0200212 if params:
213 # Append script parameters at the beginning of the script
214 header = ''.join(sorted(["{!s}={!s}\n".format(k, v)
215 for k, v in params.items()]))
216 script = header + '\n' + script
217
218 timeout = timeout or self.timeout
219 end_of_time = time.time() + timeout
220 output_data = b''
221 error_data = b''
222 exit_status = None
223
Federico Ressi6f0644e2018-07-06 10:05:32 +0200224 channel = self.open_session()
225 with channel:
226
227 # Combine STOUT and STDERR to have to handle with only one stream
228 channel.set_combine_stderr(combine_stderr)
229
Federico Ressi498a7f42018-10-22 17:44:11 +0200230 # Update local environment
231 lang, encoding = locale.getlocale()
232 if not lang:
233 lang, encoding = locale.getdefaultlocale()
234 _locale = '.'.join([lang, encoding])
235 channel.update_environment({'LC_ALL': _locale,
236 'LANG': _locale})
Federico Ressi6f0644e2018-07-06 10:05:32 +0200237
238 if become_root:
239 shell = 'sudo ' + shell
240 # Spawn a Bash
241 channel.exec_command(shell)
242
Federico Ressi498a7f42018-10-22 17:44:11 +0200243 end_of_script = False
Federico Ressi6f0644e2018-07-06 10:05:32 +0200244 lines_iterator = iter(script.splitlines())
Federico Ressi498a7f42018-10-22 17:44:11 +0200245 while (not channel.exit_status_ready() and
246 time.time() < end_of_time):
Federico Ressi6f0644e2018-07-06 10:05:32 +0200247 # Drain incoming data buffers
248 while channel.recv_ready():
249 output_data += channel.recv(self.buf_size)
250 while channel.recv_stderr_ready():
251 error_data += channel.recv_stderr(self.buf_size)
252
Federico Ressi498a7f42018-10-22 17:44:11 +0200253 if not end_of_script and channel.send_ready():
Federico Ressi6f0644e2018-07-06 10:05:32 +0200254 try:
255 line = next(lines_iterator)
256 except StopIteration:
257 # Finalize Bash script execution
258 channel.shutdown_write()
Federico Ressi498a7f42018-10-22 17:44:11 +0200259 end_of_script = True
Federico Ressi6f0644e2018-07-06 10:05:32 +0200260 else:
261 # Send script to Bash STDIN line by line
Federico Ressi498a7f42018-10-22 17:44:11 +0200262 channel.send((line + '\n').encode(encoding))
263 continue
264
265 time.sleep(.1)
Federico Ressi6f0644e2018-07-06 10:05:32 +0200266
267 # Get exit status and drain incoming data buffers
Federico Ressi498a7f42018-10-22 17:44:11 +0200268 if channel.exit_status_ready():
269 exit_status = channel.recv_exit_status()
Federico Ressi6f0644e2018-07-06 10:05:32 +0200270 while channel.recv_ready():
271 output_data += channel.recv(self.buf_size)
272 while channel.recv_stderr_ready():
273 error_data += channel.recv_stderr(self.buf_size)
274
Federico Ressi498a7f42018-10-22 17:44:11 +0200275 stdout = _buffer_to_string(output_data, encoding)
276 if exit_status == 0:
277 return stdout
Federico Ressi6f0644e2018-07-06 10:05:32 +0200278
Federico Ressi498a7f42018-10-22 17:44:11 +0200279 stderr = _buffer_to_string(error_data, encoding)
280 if exit_status is None:
281 raise exc.SSHScriptTimeoutExpired(
Federico Ressi0e04f8f2018-10-24 12:19:05 +0200282 command=shell, host=self.host, script=script, stderr=stderr,
283 stdout=stdout, timeout=timeout)
Federico Ressi498a7f42018-10-22 17:44:11 +0200284 else:
285 raise exc.SSHScriptFailed(
Federico Ressi0e04f8f2018-10-24 12:19:05 +0200286 command=shell, host=self.host, script=script, stderr=stderr,
287 stdout=stdout, exit_status=exit_status)
Federico Ressi498a7f42018-10-22 17:44:11 +0200288
289
290def _buffer_to_string(data_buffer, encoding):
291 return data_buffer.decode(encoding).replace("\r\n", "\n").replace(
292 "\r", "\n")