blob: 4cb147469be049ed1ba5a5fa17dee3b564801efa [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,
yatinkarel2ad4d582022-03-08 19:25:10 +053065 proxy_client=proxy_client,
66 ssh_key_type=CONF.validation.ssh_key_type)
Federico Ressie9c89bf2018-04-19 13:02:33 +020067
68 @classmethod
69 def create_proxy_client(cls, look_for_keys=True, **kwargs):
70 host = cls.proxy_jump_host
71 if not host:
72 # proxy_jump_host string cannot be empty or None
73 raise ValueError(
74 "'proxy_jump_host' configuration option is empty.")
75
76 # Let accept an empty string as a synonymous of default value on below
77 # options
78 password = cls.proxy_jump_password or None
79 key_file = cls.proxy_jump_keyfile or None
80 username = cls.proxy_jump_username
81
82 # Port must be a positive integer
83 port = cls.proxy_jump_port
84 if port <= 0 or port > 65535:
85 raise ValueError(
86 "Invalid value for 'proxy_jump_port' configuration option: "
87 "{!r}".format(port))
88
89 login = "{username}@{host}:{port}".format(username=username, host=host,
90 port=port)
91
92 if key_file:
93 # expand ~ character with user HOME directory
94 key_file = os.path.expanduser(key_file)
95 if os.path.isfile(key_file):
96 LOG.debug("Going to create SSH connection to %r using key "
97 "file: %s", login, key_file)
98
99 else:
100 # This message could help the user to identify a
101 # mis-configuration in tempest.conf
102 raise ValueError(
103 "Cannot find file specified as 'proxy_jump_keyfile' "
104 "option: {!r}".format(key_file))
105
106 elif password:
107 LOG.debug("Going to create SSH connection to %r using password.",
108 login)
109
110 elif look_for_keys:
111 # This message could help the user to identify a mis-configuration
112 # in tempest.conf
113 LOG.info("Both 'proxy_jump_password' and 'proxy_jump_keyfile' "
114 "options are empty. Going to create SSH connection to %r "
115 "looking for key file location into %r directory.",
116 login, os.path.expanduser('~/.ssh'))
117 else:
118 # An user that forces look_for_keys=False should really know what
119 # he really wants
120 LOG.warning("No authentication method provided to create an SSH "
121 "connection to %r. If it fails, then please "
122 "set 'proxy_jump_keyfile' to provide a valid SSH key "
123 "file.", login)
124
Federico Ressi0e04f8f2018-10-24 12:19:05 +0200125 return Client(
Federico Ressie9c89bf2018-04-19 13:02:33 +0200126 host=host, username=username, password=password,
127 look_for_keys=look_for_keys, key_filename=key_file,
Federico Ressi0e04f8f2018-10-24 12:19:05 +0200128 port=port, create_proxy_client=False, **kwargs)
Federico Ressi6f0644e2018-07-06 10:05:32 +0200129
Federico Ressi6f0644e2018-07-06 10:05:32 +0200130 def connect(self, *args, **kwargs):
131 """Creates paramiko.SSHClient and connect it to remote SSH server
132
Federico Ressi6f0644e2018-07-06 10:05:32 +0200133 :returns: paramiko.Client connected to remote server.
134
135 :raises tempest.lib.exceptions.SSHTimeout: in case it fails to connect
136 to remote server.
137 """
Dr. Jens Harbott73e15402020-01-03 12:06:12 +0000138 return super(Client, self)._get_ssh_connection(*args, **kwargs)
Federico Ressi6f0644e2018-07-06 10:05:32 +0200139
Federico Ressi498a7f42018-10-22 17:44:11 +0200140 # This overrides superclass test_connection_auth method forbidding it to
141 # close connection
142 test_connection_auth = connect
143
Federico Ressi6f0644e2018-07-06 10:05:32 +0200144 def open_session(self):
145 """Gets connection to SSH server and open a new paramiko.Channel
146
147 :returns: new paramiko.Channel
148 """
149
150 client = self.connect()
151
152 try:
153 return client.get_transport().open_session()
154 except paramiko.SSHException:
155 # the request is rejected, the session ends prematurely or
156 # there is a timeout opening a channel
157 LOG.exception("Unable to open SSH session")
158 raise exceptions.SSHTimeout(host=self.host,
159 user=self.username,
160 password=self.password)
161
Rodolfo Alonso Hernandezaa65dfb2019-09-18 11:30:04 +0000162 @tenacity.retry(
163 stop=tenacity.stop_after_attempt(10),
164 wait=tenacity.wait_fixed(1),
165 retry=tenacity.retry_if_exception_type(RETRY_EXCEPTIONS),
166 reraise=True)
Federico Ressi0e04f8f2018-10-24 12:19:05 +0200167 def exec_command(self, cmd, encoding="utf-8", timeout=None):
168 if timeout:
169 original_timeout = self.timeout
170 self.timeout = timeout
171 try:
172 return super(Client, self).exec_command(cmd=cmd, encoding=encoding)
173 finally:
174 if timeout:
175 self.timeout = original_timeout
176
Federico Ressi498a7f42018-10-22 17:44:11 +0200177 def execute_script(self, script, become_root=False, combine_stderr=False,
178 shell='sh -eux', timeout=None, **params):
Federico Ressi6f0644e2018-07-06 10:05:32 +0200179 """Connect to remote machine and executes script.
180
181 Implementation note: it passes script lines to shell interpreter via
182 STDIN. Therefore script line number could be not available to some
183 script interpreters for debugging porposes.
184
185 :param script: script lines to be executed.
186
187 :param become_root: executes interpreter as root with sudo.
188
189 :param combine_stderr (bool): whenever to redirect STDERR to STDOUT so
190 that output from both streams are returned together. True by default.
191
192 :param shell: command line used to launch script interpreter. By
193 default it executes Bash with -eux options enabled. This means that
194 any command returning non-zero exist status or any any undefined
195 variable would interrupt script execution with an error and every
196 command executed by the script is going to be traced to STDERR.
197
Federico Ressi498a7f42018-10-22 17:44:11 +0200198 :param timeout: time in seconds to wait before brutally aborting
199 script execution.
200
201 :param **params: script parameter values to be assigned at the
202 beginning of the script.
203
Federico Ressi6f0644e2018-07-06 10:05:32 +0200204 :returns output written by script to STDOUT.
205
206 :raises tempest.lib.exceptions.SSHTimeout: in case it fails to connect
207 to remote server or it fails to open a channel.
208
209 :raises tempest.lib.exceptions.SSHExecCommandFailed: in case command
Federico Ressi498a7f42018-10-22 17:44:11 +0200210 script exits with non zero exit status or times out.
Federico Ressi6f0644e2018-07-06 10:05:32 +0200211 """
212
Federico Ressi498a7f42018-10-22 17:44:11 +0200213 if params:
214 # Append script parameters at the beginning of the script
215 header = ''.join(sorted(["{!s}={!s}\n".format(k, v)
216 for k, v in params.items()]))
217 script = header + '\n' + script
218
219 timeout = timeout or self.timeout
220 end_of_time = time.time() + timeout
221 output_data = b''
222 error_data = b''
223 exit_status = None
224
Federico Ressi6f0644e2018-07-06 10:05:32 +0200225 channel = self.open_session()
226 with channel:
227
228 # Combine STOUT and STDERR to have to handle with only one stream
229 channel.set_combine_stderr(combine_stderr)
230
Federico Ressi498a7f42018-10-22 17:44:11 +0200231 # Update local environment
232 lang, encoding = locale.getlocale()
233 if not lang:
234 lang, encoding = locale.getdefaultlocale()
235 _locale = '.'.join([lang, encoding])
236 channel.update_environment({'LC_ALL': _locale,
237 'LANG': _locale})
Federico Ressi6f0644e2018-07-06 10:05:32 +0200238
239 if become_root:
240 shell = 'sudo ' + shell
241 # Spawn a Bash
242 channel.exec_command(shell)
243
Federico Ressi498a7f42018-10-22 17:44:11 +0200244 end_of_script = False
Federico Ressi6f0644e2018-07-06 10:05:32 +0200245 lines_iterator = iter(script.splitlines())
Federico Ressi498a7f42018-10-22 17:44:11 +0200246 while (not channel.exit_status_ready() and
247 time.time() < end_of_time):
Federico Ressi6f0644e2018-07-06 10:05:32 +0200248 # Drain incoming data buffers
249 while channel.recv_ready():
250 output_data += channel.recv(self.buf_size)
251 while channel.recv_stderr_ready():
252 error_data += channel.recv_stderr(self.buf_size)
253
Federico Ressi498a7f42018-10-22 17:44:11 +0200254 if not end_of_script and channel.send_ready():
Federico Ressi6f0644e2018-07-06 10:05:32 +0200255 try:
256 line = next(lines_iterator)
257 except StopIteration:
258 # Finalize Bash script execution
259 channel.shutdown_write()
Federico Ressi498a7f42018-10-22 17:44:11 +0200260 end_of_script = True
Federico Ressi6f0644e2018-07-06 10:05:32 +0200261 else:
262 # Send script to Bash STDIN line by line
Federico Ressi498a7f42018-10-22 17:44:11 +0200263 channel.send((line + '\n').encode(encoding))
264 continue
265
266 time.sleep(.1)
Federico Ressi6f0644e2018-07-06 10:05:32 +0200267
268 # Get exit status and drain incoming data buffers
Federico Ressi498a7f42018-10-22 17:44:11 +0200269 if channel.exit_status_ready():
270 exit_status = channel.recv_exit_status()
Federico Ressi6f0644e2018-07-06 10:05:32 +0200271 while channel.recv_ready():
272 output_data += channel.recv(self.buf_size)
273 while channel.recv_stderr_ready():
274 error_data += channel.recv_stderr(self.buf_size)
275
Federico Ressi498a7f42018-10-22 17:44:11 +0200276 stdout = _buffer_to_string(output_data, encoding)
277 if exit_status == 0:
278 return stdout
Federico Ressi6f0644e2018-07-06 10:05:32 +0200279
Federico Ressi498a7f42018-10-22 17:44:11 +0200280 stderr = _buffer_to_string(error_data, encoding)
281 if exit_status is None:
282 raise exc.SSHScriptTimeoutExpired(
Federico Ressi0e04f8f2018-10-24 12:19:05 +0200283 command=shell, host=self.host, script=script, stderr=stderr,
284 stdout=stdout, timeout=timeout)
Federico Ressi498a7f42018-10-22 17:44:11 +0200285 else:
286 raise exc.SSHScriptFailed(
Federico Ressi0e04f8f2018-10-24 12:19:05 +0200287 command=shell, host=self.host, script=script, stderr=stderr,
288 stdout=stdout, exit_status=exit_status)
Federico Ressi498a7f42018-10-22 17:44:11 +0200289
Rodolfo Alonso Hernandezaf394dd2020-11-12 14:26:13 +0000290 def get_hostname(self):
291 """Retrieve the remote machine hostname"""
292 try:
293 return self.exec_command('hostname')
294 except exceptions.SSHExecCommandFailed:
295 return self.exec_command('cat /etc/hostname')
296
Federico Ressi498a7f42018-10-22 17:44:11 +0200297
298def _buffer_to_string(data_buffer, encoding):
299 return data_buffer.decode(encoding).replace("\r\n", "\n").replace(
300 "\r", "\n")