blob: 33dffcb5b4584fd1f21d1e1795bc1387afc538d7 [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,
46 port=22, proxy_client=None):
47
48 timeout = timeout or self.timeout
49
50 if self.proxy_jump_host:
51 # Perform all SSH connections passing through configured SSH server
52 proxy_client = proxy_client or self.create_proxy_client(
53 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
118 return ssh.Client(
119 host=host, username=username, password=password,
120 look_for_keys=look_for_keys, key_filename=key_file,
121 port=port, proxy_client=None, **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 Ressi498a7f42018-10-22 17:44:11 +0200182 def execute_script(self, script, become_root=False, combine_stderr=False,
183 shell='sh -eux', timeout=None, **params):
Federico Ressi6f0644e2018-07-06 10:05:32 +0200184 """Connect to remote machine and executes script.
185
186 Implementation note: it passes script lines to shell interpreter via
187 STDIN. Therefore script line number could be not available to some
188 script interpreters for debugging porposes.
189
190 :param script: script lines to be executed.
191
192 :param become_root: executes interpreter as root with sudo.
193
194 :param combine_stderr (bool): whenever to redirect STDERR to STDOUT so
195 that output from both streams are returned together. True by default.
196
197 :param shell: command line used to launch script interpreter. By
198 default it executes Bash with -eux options enabled. This means that
199 any command returning non-zero exist status or any any undefined
200 variable would interrupt script execution with an error and every
201 command executed by the script is going to be traced to STDERR.
202
Federico Ressi498a7f42018-10-22 17:44:11 +0200203 :param timeout: time in seconds to wait before brutally aborting
204 script execution.
205
206 :param **params: script parameter values to be assigned at the
207 beginning of the script.
208
Federico Ressi6f0644e2018-07-06 10:05:32 +0200209 :returns output written by script to STDOUT.
210
211 :raises tempest.lib.exceptions.SSHTimeout: in case it fails to connect
212 to remote server or it fails to open a channel.
213
214 :raises tempest.lib.exceptions.SSHExecCommandFailed: in case command
Federico Ressi498a7f42018-10-22 17:44:11 +0200215 script exits with non zero exit status or times out.
Federico Ressi6f0644e2018-07-06 10:05:32 +0200216 """
217
Federico Ressi498a7f42018-10-22 17:44:11 +0200218 if params:
219 # Append script parameters at the beginning of the script
220 header = ''.join(sorted(["{!s}={!s}\n".format(k, v)
221 for k, v in params.items()]))
222 script = header + '\n' + script
223
224 timeout = timeout or self.timeout
225 end_of_time = time.time() + timeout
226 output_data = b''
227 error_data = b''
228 exit_status = None
229
Federico Ressi6f0644e2018-07-06 10:05:32 +0200230 channel = self.open_session()
231 with channel:
232
233 # Combine STOUT and STDERR to have to handle with only one stream
234 channel.set_combine_stderr(combine_stderr)
235
Federico Ressi498a7f42018-10-22 17:44:11 +0200236 # Update local environment
237 lang, encoding = locale.getlocale()
238 if not lang:
239 lang, encoding = locale.getdefaultlocale()
240 _locale = '.'.join([lang, encoding])
241 channel.update_environment({'LC_ALL': _locale,
242 'LANG': _locale})
Federico Ressi6f0644e2018-07-06 10:05:32 +0200243
244 if become_root:
245 shell = 'sudo ' + shell
246 # Spawn a Bash
247 channel.exec_command(shell)
248
Federico Ressi498a7f42018-10-22 17:44:11 +0200249 end_of_script = False
Federico Ressi6f0644e2018-07-06 10:05:32 +0200250 lines_iterator = iter(script.splitlines())
Federico Ressi498a7f42018-10-22 17:44:11 +0200251 while (not channel.exit_status_ready() and
252 time.time() < end_of_time):
Federico Ressi6f0644e2018-07-06 10:05:32 +0200253 # Drain incoming data buffers
254 while channel.recv_ready():
255 output_data += channel.recv(self.buf_size)
256 while channel.recv_stderr_ready():
257 error_data += channel.recv_stderr(self.buf_size)
258
Federico Ressi498a7f42018-10-22 17:44:11 +0200259 if not end_of_script and channel.send_ready():
Federico Ressi6f0644e2018-07-06 10:05:32 +0200260 try:
261 line = next(lines_iterator)
262 except StopIteration:
263 # Finalize Bash script execution
264 channel.shutdown_write()
Federico Ressi498a7f42018-10-22 17:44:11 +0200265 end_of_script = True
Federico Ressi6f0644e2018-07-06 10:05:32 +0200266 else:
267 # Send script to Bash STDIN line by line
Federico Ressi498a7f42018-10-22 17:44:11 +0200268 channel.send((line + '\n').encode(encoding))
269 continue
270
271 time.sleep(.1)
Federico Ressi6f0644e2018-07-06 10:05:32 +0200272
273 # Get exit status and drain incoming data buffers
Federico Ressi498a7f42018-10-22 17:44:11 +0200274 if channel.exit_status_ready():
275 exit_status = channel.recv_exit_status()
Federico Ressi6f0644e2018-07-06 10:05:32 +0200276 while channel.recv_ready():
277 output_data += channel.recv(self.buf_size)
278 while channel.recv_stderr_ready():
279 error_data += channel.recv_stderr(self.buf_size)
280
Federico Ressi498a7f42018-10-22 17:44:11 +0200281 stdout = _buffer_to_string(output_data, encoding)
282 if exit_status == 0:
283 return stdout
Federico Ressi6f0644e2018-07-06 10:05:32 +0200284
Federico Ressi498a7f42018-10-22 17:44:11 +0200285 stderr = _buffer_to_string(error_data, encoding)
286 if exit_status is None:
287 raise exc.SSHScriptTimeoutExpired(
288 host=self.host, script=script, stderr=stderr, stdout=stdout,
289 timeout=timeout)
290 else:
291 raise exc.SSHScriptFailed(
292 host=self.host, script=script, stderr=stderr, stdout=stdout,
293 exit_status=exit_status)
294
295
296def _buffer_to_string(data_buffer, encoding):
297 return data_buffer.decode(encoding).replace("\r\n", "\n").replace(
298 "\r", "\n")