| Jakub Libosvar | 7c58cb2 | 2017-05-03 09:00:14 +0000 | [diff] [blame] | 1 | # 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 Ressi | 498a7f4 | 2018-10-22 17:44:11 +0200 | [diff] [blame] | 15 | import locale | 
| Federico Ressi | e9c89bf | 2018-04-19 13:02:33 +0200 | [diff] [blame] | 16 | import os | 
| Rodolfo Alonso Hernandez | aa65dfb | 2019-09-18 11:30:04 +0000 | [diff] [blame] | 17 | import socket | 
| Federico Ressi | 6f0644e | 2018-07-06 10:05:32 +0200 | [diff] [blame] | 18 | import time | 
| Federico Ressi | e9c89bf | 2018-04-19 13:02:33 +0200 | [diff] [blame] | 19 |  | 
|  | 20 | from oslo_log import log | 
| Federico Ressi | 6f0644e | 2018-07-06 10:05:32 +0200 | [diff] [blame] | 21 | import paramiko | 
| Jakub Libosvar | 7c58cb2 | 2017-05-03 09:00:14 +0000 | [diff] [blame] | 22 | from tempest.lib.common import ssh | 
| Federico Ressi | 6f0644e | 2018-07-06 10:05:32 +0200 | [diff] [blame] | 23 | from tempest.lib import exceptions | 
| Rodolfo Alonso Hernandez | aa65dfb | 2019-09-18 11:30:04 +0000 | [diff] [blame] | 24 | import tenacity | 
| Jakub Libosvar | 7c58cb2 | 2017-05-03 09:00:14 +0000 | [diff] [blame] | 25 |  | 
| Chandan Kumar | 667d3d3 | 2017-09-22 12:24:06 +0530 | [diff] [blame] | 26 | from neutron_tempest_plugin import config | 
| Federico Ressi | 498a7f4 | 2018-10-22 17:44:11 +0200 | [diff] [blame] | 27 | from neutron_tempest_plugin import exceptions as exc | 
| Jakub Libosvar | 7c58cb2 | 2017-05-03 09:00:14 +0000 | [diff] [blame] | 28 |  | 
|  | 29 |  | 
| Federico Ressi | e9c89bf | 2018-04-19 13:02:33 +0200 | [diff] [blame] | 30 | CONF = config.CONF | 
|  | 31 | LOG = log.getLogger(__name__) | 
|  | 32 |  | 
|  | 33 |  | 
| Rodolfo Alonso Hernandez | aa65dfb | 2019-09-18 11:30:04 +0000 | [diff] [blame] | 34 | RETRY_EXCEPTIONS = (exceptions.TimeoutException, paramiko.SSHException, | 
| wangzihao | 8e4c6dd | 2020-11-04 09:30:48 +0800 | [diff] [blame] | 35 | socket.error, TimeoutError) | 
| Rodolfo Alonso Hernandez | aa65dfb | 2019-09-18 11:30:04 +0000 | [diff] [blame] | 36 |  | 
|  | 37 |  | 
| Jakub Libosvar | 7c58cb2 | 2017-05-03 09:00:14 +0000 | [diff] [blame] | 38 | class Client(ssh.Client): | 
| Federico Ressi | e9c89bf | 2018-04-19 13:02:33 +0200 | [diff] [blame] | 39 |  | 
| Federico Ressi | 6f0644e | 2018-07-06 10:05:32 +0200 | [diff] [blame] | 40 | default_ssh_lang = 'en_US.UTF-8' | 
|  | 41 |  | 
| Federico Ressi | e9c89bf | 2018-04-19 13:02:33 +0200 | [diff] [blame] | 42 | 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 Ressi | 0e04f8f | 2018-10-24 12:19:05 +0200 | [diff] [blame] | 52 | port=22, proxy_client=None, create_proxy_client=True): | 
| Federico Ressi | e9c89bf | 2018-04-19 13:02:33 +0200 | [diff] [blame] | 53 |  | 
|  | 54 | timeout = timeout or self.timeout | 
|  | 55 |  | 
| Federico Ressi | 0e04f8f | 2018-10-24 12:19:05 +0200 | [diff] [blame] | 56 | if not proxy_client and create_proxy_client and self.proxy_jump_host: | 
| Federico Ressi | e9c89bf | 2018-04-19 13:02:33 +0200 | [diff] [blame] | 57 | # Perform all SSH connections passing through configured SSH server | 
| Federico Ressi | 0e04f8f | 2018-10-24 12:19:05 +0200 | [diff] [blame] | 58 | proxy_client = self.create_proxy_client( | 
| Federico Ressi | e9c89bf | 2018-04-19 13:02:33 +0200 | [diff] [blame] | 59 | 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, | 
| yatinkarel | 2ad4d58 | 2022-03-08 19:25:10 +0530 | [diff] [blame] | 65 | proxy_client=proxy_client, | 
|  | 66 | ssh_key_type=CONF.validation.ssh_key_type) | 
| Federico Ressi | e9c89bf | 2018-04-19 13:02:33 +0200 | [diff] [blame] | 67 |  | 
|  | 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 Ressi | 0e04f8f | 2018-10-24 12:19:05 +0200 | [diff] [blame] | 125 | return Client( | 
| Federico Ressi | e9c89bf | 2018-04-19 13:02:33 +0200 | [diff] [blame] | 126 | host=host, username=username, password=password, | 
|  | 127 | look_for_keys=look_for_keys, key_filename=key_file, | 
| Federico Ressi | 0e04f8f | 2018-10-24 12:19:05 +0200 | [diff] [blame] | 128 | port=port, create_proxy_client=False, **kwargs) | 
| Federico Ressi | 6f0644e | 2018-07-06 10:05:32 +0200 | [diff] [blame] | 129 |  | 
| Federico Ressi | 6f0644e | 2018-07-06 10:05:32 +0200 | [diff] [blame] | 130 | def connect(self, *args, **kwargs): | 
|  | 131 | """Creates paramiko.SSHClient and connect it to remote SSH server | 
|  | 132 |  | 
| Federico Ressi | 6f0644e | 2018-07-06 10:05:32 +0200 | [diff] [blame] | 133 | :returns: paramiko.Client connected to remote server. | 
|  | 134 |  | 
|  | 135 | :raises tempest.lib.exceptions.SSHTimeout: in case it fails to connect | 
| Elod Illes | f2e985e | 2023-11-06 19:30:29 +0100 | [diff] [blame] | 136 | to remote server. | 
| Federico Ressi | 6f0644e | 2018-07-06 10:05:32 +0200 | [diff] [blame] | 137 | """ | 
| Dr. Jens Harbott | 73e1540 | 2020-01-03 12:06:12 +0000 | [diff] [blame] | 138 | return super(Client, self)._get_ssh_connection(*args, **kwargs) | 
| Federico Ressi | 6f0644e | 2018-07-06 10:05:32 +0200 | [diff] [blame] | 139 |  | 
| Federico Ressi | 498a7f4 | 2018-10-22 17:44:11 +0200 | [diff] [blame] | 140 | # This overrides superclass test_connection_auth method forbidding it to | 
|  | 141 | # close connection | 
|  | 142 | test_connection_auth = connect | 
|  | 143 |  | 
| Federico Ressi | 6f0644e | 2018-07-06 10:05:32 +0200 | [diff] [blame] | 144 | 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 Hernandez | aa65dfb | 2019-09-18 11:30:04 +0000 | [diff] [blame] | 162 | @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 Ressi | 0e04f8f | 2018-10-24 12:19:05 +0200 | [diff] [blame] | 167 | 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 Ressi | 498a7f4 | 2018-10-22 17:44:11 +0200 | [diff] [blame] | 177 | def execute_script(self, script, become_root=False, combine_stderr=False, | 
|  | 178 | shell='sh -eux', timeout=None, **params): | 
| Federico Ressi | 6f0644e | 2018-07-06 10:05:32 +0200 | [diff] [blame] | 179 | """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 Ressi | 498a7f4 | 2018-10-22 17:44:11 +0200 | [diff] [blame] | 198 | :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 Ressi | 6f0644e | 2018-07-06 10:05:32 +0200 | [diff] [blame] | 204 | :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 Ressi | 498a7f4 | 2018-10-22 17:44:11 +0200 | [diff] [blame] | 210 | script exits with non zero exit status or times out. | 
| Federico Ressi | 6f0644e | 2018-07-06 10:05:32 +0200 | [diff] [blame] | 211 | """ | 
|  | 212 |  | 
| Federico Ressi | 498a7f4 | 2018-10-22 17:44:11 +0200 | [diff] [blame] | 213 | 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 Ressi | 6f0644e | 2018-07-06 10:05:32 +0200 | [diff] [blame] | 225 | 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 Ressi | 498a7f4 | 2018-10-22 17:44:11 +0200 | [diff] [blame] | 231 | # 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 Ressi | 6f0644e | 2018-07-06 10:05:32 +0200 | [diff] [blame] | 238 |  | 
|  | 239 | if become_root: | 
|  | 240 | shell = 'sudo ' + shell | 
|  | 241 | # Spawn a Bash | 
|  | 242 | channel.exec_command(shell) | 
|  | 243 |  | 
| Federico Ressi | 498a7f4 | 2018-10-22 17:44:11 +0200 | [diff] [blame] | 244 | end_of_script = False | 
| Federico Ressi | 6f0644e | 2018-07-06 10:05:32 +0200 | [diff] [blame] | 245 | lines_iterator = iter(script.splitlines()) | 
| Federico Ressi | 498a7f4 | 2018-10-22 17:44:11 +0200 | [diff] [blame] | 246 | while (not channel.exit_status_ready() and | 
|  | 247 | time.time() < end_of_time): | 
| Federico Ressi | 6f0644e | 2018-07-06 10:05:32 +0200 | [diff] [blame] | 248 | # 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 Ressi | 498a7f4 | 2018-10-22 17:44:11 +0200 | [diff] [blame] | 254 | if not end_of_script and channel.send_ready(): | 
| Federico Ressi | 6f0644e | 2018-07-06 10:05:32 +0200 | [diff] [blame] | 255 | try: | 
|  | 256 | line = next(lines_iterator) | 
|  | 257 | except StopIteration: | 
|  | 258 | # Finalize Bash script execution | 
|  | 259 | channel.shutdown_write() | 
| Federico Ressi | 498a7f4 | 2018-10-22 17:44:11 +0200 | [diff] [blame] | 260 | end_of_script = True | 
| Federico Ressi | 6f0644e | 2018-07-06 10:05:32 +0200 | [diff] [blame] | 261 | else: | 
|  | 262 | # Send script to Bash STDIN line by line | 
| Federico Ressi | 498a7f4 | 2018-10-22 17:44:11 +0200 | [diff] [blame] | 263 | channel.send((line + '\n').encode(encoding)) | 
|  | 264 | continue | 
|  | 265 |  | 
|  | 266 | time.sleep(.1) | 
| Federico Ressi | 6f0644e | 2018-07-06 10:05:32 +0200 | [diff] [blame] | 267 |  | 
|  | 268 | # Get exit status and drain incoming data buffers | 
| Federico Ressi | 498a7f4 | 2018-10-22 17:44:11 +0200 | [diff] [blame] | 269 | if channel.exit_status_ready(): | 
|  | 270 | exit_status = channel.recv_exit_status() | 
| Federico Ressi | 6f0644e | 2018-07-06 10:05:32 +0200 | [diff] [blame] | 271 | 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 Ressi | 498a7f4 | 2018-10-22 17:44:11 +0200 | [diff] [blame] | 276 | stdout = _buffer_to_string(output_data, encoding) | 
|  | 277 | if exit_status == 0: | 
|  | 278 | return stdout | 
| Federico Ressi | 6f0644e | 2018-07-06 10:05:32 +0200 | [diff] [blame] | 279 |  | 
| Federico Ressi | 498a7f4 | 2018-10-22 17:44:11 +0200 | [diff] [blame] | 280 | stderr = _buffer_to_string(error_data, encoding) | 
|  | 281 | if exit_status is None: | 
|  | 282 | raise exc.SSHScriptTimeoutExpired( | 
| Federico Ressi | 0e04f8f | 2018-10-24 12:19:05 +0200 | [diff] [blame] | 283 | command=shell, host=self.host, script=script, stderr=stderr, | 
|  | 284 | stdout=stdout, timeout=timeout) | 
| Federico Ressi | 498a7f4 | 2018-10-22 17:44:11 +0200 | [diff] [blame] | 285 | else: | 
|  | 286 | raise exc.SSHScriptFailed( | 
| Federico Ressi | 0e04f8f | 2018-10-24 12:19:05 +0200 | [diff] [blame] | 287 | command=shell, host=self.host, script=script, stderr=stderr, | 
|  | 288 | stdout=stdout, exit_status=exit_status) | 
| Federico Ressi | 498a7f4 | 2018-10-22 17:44:11 +0200 | [diff] [blame] | 289 |  | 
| Rodolfo Alonso Hernandez | af394dd | 2020-11-12 14:26:13 +0000 | [diff] [blame] | 290 | 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 Ressi | 498a7f4 | 2018-10-22 17:44:11 +0200 | [diff] [blame] | 297 |  | 
|  | 298 | def _buffer_to_string(data_buffer, encoding): | 
|  | 299 | return data_buffer.decode(encoding).replace("\r\n", "\n").replace( | 
|  | 300 | "\r", "\n") |