blob: ea30a282001e487512fe3589f9e351cb68f2810a [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
Federico Ressi6f0644e2018-07-06 10:05:32 +020017import time
Federico Ressie9c89bf2018-04-19 13:02:33 +020018
19from oslo_log import log
Federico Ressi6f0644e2018-07-06 10:05:32 +020020import paramiko
Jakub Libosvar7c58cb22017-05-03 09:00:14 +000021from tempest.lib.common import ssh
Federico Ressi6f0644e2018-07-06 10:05:32 +020022from tempest.lib import exceptions
Jakub Libosvar7c58cb22017-05-03 09:00:14 +000023
Chandan Kumar667d3d32017-09-22 12:24:06 +053024from neutron_tempest_plugin import config
Federico Ressi498a7f42018-10-22 17:44:11 +020025from neutron_tempest_plugin import exceptions as exc
Jakub Libosvar7c58cb22017-05-03 09:00:14 +000026
27
Federico Ressie9c89bf2018-04-19 13:02:33 +020028CONF = config.CONF
29LOG = log.getLogger(__name__)
30
31
Jakub Libosvar7c58cb22017-05-03 09:00:14 +000032class Client(ssh.Client):
Federico Ressie9c89bf2018-04-19 13:02:33 +020033
Federico Ressi6f0644e2018-07-06 10:05:32 +020034 default_ssh_lang = 'en_US.UTF-8'
35
Federico Ressie9c89bf2018-04-19 13:02:33 +020036 timeout = CONF.validation.ssh_timeout
37
38 proxy_jump_host = CONF.neutron_plugin_options.ssh_proxy_jump_host
39 proxy_jump_username = CONF.neutron_plugin_options.ssh_proxy_jump_username
40 proxy_jump_password = CONF.neutron_plugin_options.ssh_proxy_jump_password
41 proxy_jump_keyfile = CONF.neutron_plugin_options.ssh_proxy_jump_keyfile
42 proxy_jump_port = CONF.neutron_plugin_options.ssh_proxy_jump_port
43
44 def __init__(self, host, username, password=None, timeout=None, pkey=None,
45 channel_timeout=10, look_for_keys=False, key_filename=None,
Federico Ressi0e04f8f2018-10-24 12:19:05 +020046 port=22, proxy_client=None, create_proxy_client=True):
Federico Ressie9c89bf2018-04-19 13:02:33 +020047
48 timeout = timeout or self.timeout
49
Federico Ressi0e04f8f2018-10-24 12:19:05 +020050 if not proxy_client and create_proxy_client and self.proxy_jump_host:
Federico Ressie9c89bf2018-04-19 13:02:33 +020051 # Perform all SSH connections passing through configured SSH server
Federico Ressi0e04f8f2018-10-24 12:19:05 +020052 proxy_client = self.create_proxy_client(
Federico Ressie9c89bf2018-04-19 13:02:33 +020053 timeout=timeout, channel_timeout=channel_timeout)
54
55 super(Client, self).__init__(
56 host=host, username=username, password=password, timeout=timeout,
57 pkey=pkey, channel_timeout=channel_timeout,
58 look_for_keys=look_for_keys, key_filename=key_filename, port=port,
59 proxy_client=proxy_client)
60
61 @classmethod
62 def create_proxy_client(cls, look_for_keys=True, **kwargs):
63 host = cls.proxy_jump_host
64 if not host:
65 # proxy_jump_host string cannot be empty or None
66 raise ValueError(
67 "'proxy_jump_host' configuration option is empty.")
68
69 # Let accept an empty string as a synonymous of default value on below
70 # options
71 password = cls.proxy_jump_password or None
72 key_file = cls.proxy_jump_keyfile or None
73 username = cls.proxy_jump_username
74
75 # Port must be a positive integer
76 port = cls.proxy_jump_port
77 if port <= 0 or port > 65535:
78 raise ValueError(
79 "Invalid value for 'proxy_jump_port' configuration option: "
80 "{!r}".format(port))
81
82 login = "{username}@{host}:{port}".format(username=username, host=host,
83 port=port)
84
85 if key_file:
86 # expand ~ character with user HOME directory
87 key_file = os.path.expanduser(key_file)
88 if os.path.isfile(key_file):
89 LOG.debug("Going to create SSH connection to %r using key "
90 "file: %s", login, key_file)
91
92 else:
93 # This message could help the user to identify a
94 # mis-configuration in tempest.conf
95 raise ValueError(
96 "Cannot find file specified as 'proxy_jump_keyfile' "
97 "option: {!r}".format(key_file))
98
99 elif password:
100 LOG.debug("Going to create SSH connection to %r using password.",
101 login)
102
103 elif look_for_keys:
104 # This message could help the user to identify a mis-configuration
105 # in tempest.conf
106 LOG.info("Both 'proxy_jump_password' and 'proxy_jump_keyfile' "
107 "options are empty. Going to create SSH connection to %r "
108 "looking for key file location into %r directory.",
109 login, os.path.expanduser('~/.ssh'))
110 else:
111 # An user that forces look_for_keys=False should really know what
112 # he really wants
113 LOG.warning("No authentication method provided to create an SSH "
114 "connection to %r. If it fails, then please "
115 "set 'proxy_jump_keyfile' to provide a valid SSH key "
116 "file.", login)
117
Federico Ressi0e04f8f2018-10-24 12:19:05 +0200118 return Client(
Federico Ressie9c89bf2018-04-19 13:02:33 +0200119 host=host, username=username, password=password,
120 look_for_keys=look_for_keys, key_filename=key_file,
Federico Ressi0e04f8f2018-10-24 12:19:05 +0200121 port=port, create_proxy_client=False, **kwargs)
Federico Ressi6f0644e2018-07-06 10:05:32 +0200122
123 # attribute used to keep reference to opened client connection
124 _client = None
125
126 def connect(self, *args, **kwargs):
127 """Creates paramiko.SSHClient and connect it to remote SSH server
128
129 In case this method is called more times it returns the same client
130 and no new SSH connection is created until close method is called.
131
132 :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 """
137 client = self._client
138 if client is None:
139 client = super(Client, self)._get_ssh_connection(
140 *args, **kwargs)
141 self._client = client
142
143 return client
144
145 # This overrides superclass protected method to make sure exec_command
146 # method is going to reuse the same SSH client and connection if called
147 # more times
148 _get_ssh_connection = connect
149
Federico Ressi498a7f42018-10-22 17:44:11 +0200150 # This overrides superclass test_connection_auth method forbidding it to
151 # close connection
152 test_connection_auth = connect
153
Federico Ressi6f0644e2018-07-06 10:05:32 +0200154 def close(self):
Brian Haleyaee61ac2018-10-09 20:00:27 -0400155 """Closes connection to SSH server and cleanup resources."""
Federico Ressi6f0644e2018-07-06 10:05:32 +0200156 client = self._client
157 if client is not None:
158 client.close()
159 self._client = None
160
Federico Ressi498a7f42018-10-22 17:44:11 +0200161 def __exit__(self, _exception_type, _exception_value, _traceback):
162 self.close()
163
Federico Ressi6f0644e2018-07-06 10:05:32 +0200164 def open_session(self):
165 """Gets connection to SSH server and open a new paramiko.Channel
166
167 :returns: new paramiko.Channel
168 """
169
170 client = self.connect()
171
172 try:
173 return client.get_transport().open_session()
174 except paramiko.SSHException:
175 # the request is rejected, the session ends prematurely or
176 # there is a timeout opening a channel
177 LOG.exception("Unable to open SSH session")
178 raise exceptions.SSHTimeout(host=self.host,
179 user=self.username,
180 password=self.password)
181
Federico Ressi0e04f8f2018-10-24 12:19:05 +0200182 def exec_command(self, cmd, encoding="utf-8", timeout=None):
183 if timeout:
184 original_timeout = self.timeout
185 self.timeout = timeout
186 try:
187 return super(Client, self).exec_command(cmd=cmd, encoding=encoding)
188 finally:
189 if timeout:
190 self.timeout = original_timeout
191
Federico Ressi498a7f42018-10-22 17:44:11 +0200192 def execute_script(self, script, become_root=False, combine_stderr=False,
193 shell='sh -eux', timeout=None, **params):
Federico Ressi6f0644e2018-07-06 10:05:32 +0200194 """Connect to remote machine and executes script.
195
196 Implementation note: it passes script lines to shell interpreter via
197 STDIN. Therefore script line number could be not available to some
198 script interpreters for debugging porposes.
199
200 :param script: script lines to be executed.
201
202 :param become_root: executes interpreter as root with sudo.
203
204 :param combine_stderr (bool): whenever to redirect STDERR to STDOUT so
205 that output from both streams are returned together. True by default.
206
207 :param shell: command line used to launch script interpreter. By
208 default it executes Bash with -eux options enabled. This means that
209 any command returning non-zero exist status or any any undefined
210 variable would interrupt script execution with an error and every
211 command executed by the script is going to be traced to STDERR.
212
Federico Ressi498a7f42018-10-22 17:44:11 +0200213 :param timeout: time in seconds to wait before brutally aborting
214 script execution.
215
216 :param **params: script parameter values to be assigned at the
217 beginning of the script.
218
Federico Ressi6f0644e2018-07-06 10:05:32 +0200219 :returns output written by script to STDOUT.
220
221 :raises tempest.lib.exceptions.SSHTimeout: in case it fails to connect
222 to remote server or it fails to open a channel.
223
224 :raises tempest.lib.exceptions.SSHExecCommandFailed: in case command
Federico Ressi498a7f42018-10-22 17:44:11 +0200225 script exits with non zero exit status or times out.
Federico Ressi6f0644e2018-07-06 10:05:32 +0200226 """
227
Federico Ressi498a7f42018-10-22 17:44:11 +0200228 if params:
229 # Append script parameters at the beginning of the script
230 header = ''.join(sorted(["{!s}={!s}\n".format(k, v)
231 for k, v in params.items()]))
232 script = header + '\n' + script
233
234 timeout = timeout or self.timeout
235 end_of_time = time.time() + timeout
236 output_data = b''
237 error_data = b''
238 exit_status = None
239
Federico Ressi6f0644e2018-07-06 10:05:32 +0200240 channel = self.open_session()
241 with channel:
242
243 # Combine STOUT and STDERR to have to handle with only one stream
244 channel.set_combine_stderr(combine_stderr)
245
Federico Ressi498a7f42018-10-22 17:44:11 +0200246 # Update local environment
247 lang, encoding = locale.getlocale()
248 if not lang:
249 lang, encoding = locale.getdefaultlocale()
250 _locale = '.'.join([lang, encoding])
251 channel.update_environment({'LC_ALL': _locale,
252 'LANG': _locale})
Federico Ressi6f0644e2018-07-06 10:05:32 +0200253
254 if become_root:
255 shell = 'sudo ' + shell
256 # Spawn a Bash
257 channel.exec_command(shell)
258
Federico Ressi498a7f42018-10-22 17:44:11 +0200259 end_of_script = False
Federico Ressi6f0644e2018-07-06 10:05:32 +0200260 lines_iterator = iter(script.splitlines())
Federico Ressi498a7f42018-10-22 17:44:11 +0200261 while (not channel.exit_status_ready() and
262 time.time() < end_of_time):
Federico Ressi6f0644e2018-07-06 10:05:32 +0200263 # Drain incoming data buffers
264 while channel.recv_ready():
265 output_data += channel.recv(self.buf_size)
266 while channel.recv_stderr_ready():
267 error_data += channel.recv_stderr(self.buf_size)
268
Federico Ressi498a7f42018-10-22 17:44:11 +0200269 if not end_of_script and channel.send_ready():
Federico Ressi6f0644e2018-07-06 10:05:32 +0200270 try:
271 line = next(lines_iterator)
272 except StopIteration:
273 # Finalize Bash script execution
274 channel.shutdown_write()
Federico Ressi498a7f42018-10-22 17:44:11 +0200275 end_of_script = True
Federico Ressi6f0644e2018-07-06 10:05:32 +0200276 else:
277 # Send script to Bash STDIN line by line
Federico Ressi498a7f42018-10-22 17:44:11 +0200278 channel.send((line + '\n').encode(encoding))
279 continue
280
281 time.sleep(.1)
Federico Ressi6f0644e2018-07-06 10:05:32 +0200282
283 # Get exit status and drain incoming data buffers
Federico Ressi498a7f42018-10-22 17:44:11 +0200284 if channel.exit_status_ready():
285 exit_status = channel.recv_exit_status()
Federico Ressi6f0644e2018-07-06 10:05:32 +0200286 while channel.recv_ready():
287 output_data += channel.recv(self.buf_size)
288 while channel.recv_stderr_ready():
289 error_data += channel.recv_stderr(self.buf_size)
290
Federico Ressi498a7f42018-10-22 17:44:11 +0200291 stdout = _buffer_to_string(output_data, encoding)
292 if exit_status == 0:
293 return stdout
Federico Ressi6f0644e2018-07-06 10:05:32 +0200294
Federico Ressi498a7f42018-10-22 17:44:11 +0200295 stderr = _buffer_to_string(error_data, encoding)
296 if exit_status is None:
297 raise exc.SSHScriptTimeoutExpired(
Federico Ressi0e04f8f2018-10-24 12:19:05 +0200298 command=shell, host=self.host, script=script, stderr=stderr,
299 stdout=stdout, timeout=timeout)
Federico Ressi498a7f42018-10-22 17:44:11 +0200300 else:
301 raise exc.SSHScriptFailed(
Federico Ressi0e04f8f2018-10-24 12:19:05 +0200302 command=shell, host=self.host, script=script, stderr=stderr,
303 stdout=stdout, exit_status=exit_status)
Federico Ressi498a7f42018-10-22 17:44:11 +0200304
305
306def _buffer_to_string(data_buffer, encoding):
307 return data_buffer.decode(encoding).replace("\r\n", "\n").replace(
308 "\r", "\n")