blob: b0dd9c15f41ca7b70332f67f089f65ef8f19c8ca [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
Rodolfo Alonso Hernandez80df3662025-08-28 09:04:14 +000020from neutron_lib._i18n import _
Federico Ressie9c89bf2018-04-19 13:02:33 +020021from oslo_log import log
Federico Ressi6f0644e2018-07-06 10:05:32 +020022import paramiko
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,
wangzihao8e4c6dd2020-11-04 09:30:48 +080036 socket.error, TimeoutError)
Rodolfo Alonso Hernandezaa65dfb2019-09-18 11:30:04 +000037
38
Jakub Libosvar7c58cb22017-05-03 09:00:14 +000039class Client(ssh.Client):
Federico Ressie9c89bf2018-04-19 13:02:33 +020040
Federico Ressi6f0644e2018-07-06 10:05:32 +020041 default_ssh_lang = 'en_US.UTF-8'
42
Federico Ressie9c89bf2018-04-19 13:02:33 +020043 timeout = CONF.validation.ssh_timeout
44
45 proxy_jump_host = CONF.neutron_plugin_options.ssh_proxy_jump_host
46 proxy_jump_username = CONF.neutron_plugin_options.ssh_proxy_jump_username
47 proxy_jump_password = CONF.neutron_plugin_options.ssh_proxy_jump_password
48 proxy_jump_keyfile = CONF.neutron_plugin_options.ssh_proxy_jump_keyfile
49 proxy_jump_port = CONF.neutron_plugin_options.ssh_proxy_jump_port
50
51 def __init__(self, host, username, password=None, timeout=None, pkey=None,
52 channel_timeout=10, look_for_keys=False, key_filename=None,
Federico Ressi0e04f8f2018-10-24 12:19:05 +020053 port=22, proxy_client=None, create_proxy_client=True):
Federico Ressie9c89bf2018-04-19 13:02:33 +020054
55 timeout = timeout or self.timeout
56
Federico Ressi0e04f8f2018-10-24 12:19:05 +020057 if not proxy_client and create_proxy_client and self.proxy_jump_host:
Federico Ressie9c89bf2018-04-19 13:02:33 +020058 # Perform all SSH connections passing through configured SSH server
Federico Ressi0e04f8f2018-10-24 12:19:05 +020059 proxy_client = self.create_proxy_client(
Federico Ressie9c89bf2018-04-19 13:02:33 +020060 timeout=timeout, channel_timeout=channel_timeout)
61
62 super(Client, self).__init__(
63 host=host, username=username, password=password, timeout=timeout,
64 pkey=pkey, channel_timeout=channel_timeout,
65 look_for_keys=look_for_keys, key_filename=key_filename, port=port,
yatinkarel2ad4d582022-03-08 19:25:10 +053066 proxy_client=proxy_client,
67 ssh_key_type=CONF.validation.ssh_key_type)
Federico Ressie9c89bf2018-04-19 13:02:33 +020068
69 @classmethod
70 def create_proxy_client(cls, look_for_keys=True, **kwargs):
71 host = cls.proxy_jump_host
72 if not host:
73 # proxy_jump_host string cannot be empty or None
Rodolfo Alonso Hernandez80df3662025-08-28 09:04:14 +000074 raise ValueError(_(
75 "'proxy_jump_host' configuration option is empty."))
Federico Ressie9c89bf2018-04-19 13:02:33 +020076
77 # Let accept an empty string as a synonymous of default value on below
78 # options
79 password = cls.proxy_jump_password or None
80 key_file = cls.proxy_jump_keyfile or None
81 username = cls.proxy_jump_username
82
83 # Port must be a positive integer
84 port = cls.proxy_jump_port
85 if port <= 0 or port > 65535:
Rodolfo Alonso Hernandez80df3662025-08-28 09:04:14 +000086 raise ValueError(_(
Federico Ressie9c89bf2018-04-19 13:02:33 +020087 "Invalid value for 'proxy_jump_port' configuration option: "
Rodolfo Alonso Hernandez80df3662025-08-28 09:04:14 +000088 "{!r}".format(port)))
Federico Ressie9c89bf2018-04-19 13:02:33 +020089
90 login = "{username}@{host}:{port}".format(username=username, host=host,
91 port=port)
92
93 if key_file:
94 # expand ~ character with user HOME directory
95 key_file = os.path.expanduser(key_file)
96 if os.path.isfile(key_file):
97 LOG.debug("Going to create SSH connection to %r using key "
98 "file: %s", login, key_file)
99
100 else:
101 # This message could help the user to identify a
102 # mis-configuration in tempest.conf
Rodolfo Alonso Hernandez80df3662025-08-28 09:04:14 +0000103 raise ValueError(_(
Federico Ressie9c89bf2018-04-19 13:02:33 +0200104 "Cannot find file specified as 'proxy_jump_keyfile' "
Rodolfo Alonso Hernandez80df3662025-08-28 09:04:14 +0000105 "option: {!r}".format(key_file)))
Federico Ressie9c89bf2018-04-19 13:02:33 +0200106
107 elif password:
108 LOG.debug("Going to create SSH connection to %r using password.",
109 login)
110
111 elif look_for_keys:
112 # This message could help the user to identify a mis-configuration
113 # in tempest.conf
114 LOG.info("Both 'proxy_jump_password' and 'proxy_jump_keyfile' "
115 "options are empty. Going to create SSH connection to %r "
116 "looking for key file location into %r directory.",
117 login, os.path.expanduser('~/.ssh'))
118 else:
119 # An user that forces look_for_keys=False should really know what
120 # he really wants
121 LOG.warning("No authentication method provided to create an SSH "
122 "connection to %r. If it fails, then please "
123 "set 'proxy_jump_keyfile' to provide a valid SSH key "
124 "file.", login)
125
Federico Ressi0e04f8f2018-10-24 12:19:05 +0200126 return Client(
Federico Ressie9c89bf2018-04-19 13:02:33 +0200127 host=host, username=username, password=password,
128 look_for_keys=look_for_keys, key_filename=key_file,
Federico Ressi0e04f8f2018-10-24 12:19:05 +0200129 port=port, create_proxy_client=False, **kwargs)
Federico Ressi6f0644e2018-07-06 10:05:32 +0200130
Federico Ressi6f0644e2018-07-06 10:05:32 +0200131 def connect(self, *args, **kwargs):
132 """Creates paramiko.SSHClient and connect it to remote SSH server
133
Federico Ressi6f0644e2018-07-06 10:05:32 +0200134 :returns: paramiko.Client connected to remote server.
135
136 :raises tempest.lib.exceptions.SSHTimeout: in case it fails to connect
Elod Illesf2e985e2023-11-06 19:30:29 +0100137 to remote server.
Federico Ressi6f0644e2018-07-06 10:05:32 +0200138 """
Dr. Jens Harbott73e15402020-01-03 12:06:12 +0000139 return super(Client, self)._get_ssh_connection(*args, **kwargs)
Federico Ressi6f0644e2018-07-06 10:05:32 +0200140
Federico Ressi498a7f42018-10-22 17:44:11 +0200141 # This overrides superclass test_connection_auth method forbidding it to
142 # close connection
143 test_connection_auth = connect
144
Federico Ressi6f0644e2018-07-06 10:05:32 +0200145 def open_session(self):
146 """Gets connection to SSH server and open a new paramiko.Channel
147
148 :returns: new paramiko.Channel
149 """
150
151 client = self.connect()
152
153 try:
154 return client.get_transport().open_session()
155 except paramiko.SSHException:
156 # the request is rejected, the session ends prematurely or
157 # there is a timeout opening a channel
158 LOG.exception("Unable to open SSH session")
159 raise exceptions.SSHTimeout(host=self.host,
160 user=self.username,
161 password=self.password)
162
Rodolfo Alonso Hernandezaa65dfb2019-09-18 11:30:04 +0000163 @tenacity.retry(
164 stop=tenacity.stop_after_attempt(10),
165 wait=tenacity.wait_fixed(1),
166 retry=tenacity.retry_if_exception_type(RETRY_EXCEPTIONS),
167 reraise=True)
Federico Ressi0e04f8f2018-10-24 12:19:05 +0200168 def exec_command(self, cmd, encoding="utf-8", timeout=None):
169 if timeout:
170 original_timeout = self.timeout
171 self.timeout = timeout
172 try:
173 return super(Client, self).exec_command(cmd=cmd, encoding=encoding)
174 finally:
175 if timeout:
176 self.timeout = original_timeout
177
Federico Ressi498a7f42018-10-22 17:44:11 +0200178 def execute_script(self, script, become_root=False, combine_stderr=False,
179 shell='sh -eux', timeout=None, **params):
Federico Ressi6f0644e2018-07-06 10:05:32 +0200180 """Connect to remote machine and executes script.
181
182 Implementation note: it passes script lines to shell interpreter via
183 STDIN. Therefore script line number could be not available to some
184 script interpreters for debugging porposes.
185
186 :param script: script lines to be executed.
187
188 :param become_root: executes interpreter as root with sudo.
189
190 :param combine_stderr (bool): whenever to redirect STDERR to STDOUT so
191 that output from both streams are returned together. True by default.
192
193 :param shell: command line used to launch script interpreter. By
194 default it executes Bash with -eux options enabled. This means that
195 any command returning non-zero exist status or any any undefined
196 variable would interrupt script execution with an error and every
197 command executed by the script is going to be traced to STDERR.
198
Federico Ressi498a7f42018-10-22 17:44:11 +0200199 :param timeout: time in seconds to wait before brutally aborting
200 script execution.
201
202 :param **params: script parameter values to be assigned at the
203 beginning of the script.
204
Federico Ressi6f0644e2018-07-06 10:05:32 +0200205 :returns output written by script to STDOUT.
206
207 :raises tempest.lib.exceptions.SSHTimeout: in case it fails to connect
208 to remote server or it fails to open a channel.
209
210 :raises tempest.lib.exceptions.SSHExecCommandFailed: in case command
Federico Ressi498a7f42018-10-22 17:44:11 +0200211 script exits with non zero exit status or times out.
Federico Ressi6f0644e2018-07-06 10:05:32 +0200212 """
213
Federico Ressi498a7f42018-10-22 17:44:11 +0200214 if params:
215 # Append script parameters at the beginning of the script
216 header = ''.join(sorted(["{!s}={!s}\n".format(k, v)
217 for k, v in params.items()]))
218 script = header + '\n' + script
219
220 timeout = timeout or self.timeout
221 end_of_time = time.time() + timeout
222 output_data = b''
223 error_data = b''
224 exit_status = None
225
Federico Ressi6f0644e2018-07-06 10:05:32 +0200226 channel = self.open_session()
227 with channel:
228
229 # Combine STOUT and STDERR to have to handle with only one stream
230 channel.set_combine_stderr(combine_stderr)
231
Federico Ressi498a7f42018-10-22 17:44:11 +0200232 # Update local environment
233 lang, encoding = locale.getlocale()
Federico Ressi498a7f42018-10-22 17:44:11 +0200234 _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
Rodolfo Alonso Hernandezaf394dd2020-11-12 14:26:13 +0000289 def get_hostname(self):
290 """Retrieve the remote machine hostname"""
291 try:
292 return self.exec_command('hostname')
293 except exceptions.SSHExecCommandFailed:
294 return self.exec_command('cat /etc/hostname')
295
Federico Ressi498a7f42018-10-22 17:44:11 +0200296
297def _buffer_to_string(data_buffer, encoding):
298 return data_buffer.decode(encoding).replace("\r\n", "\n").replace(
299 "\r", "\n")