blob: 9812f4c356399bb2e01eed9935d9127c42f0d8c6 [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 Ressie9c89bf2018-04-19 13:02:33 +020015import os
Federico Ressi6f0644e2018-07-06 10:05:32 +020016import time
Federico Ressie9c89bf2018-04-19 13:02:33 +020017
18from oslo_log import log
Federico Ressi6f0644e2018-07-06 10:05:32 +020019import paramiko
Jakub Libosvar7c58cb22017-05-03 09:00:14 +000020from tempest.lib.common import ssh
Federico Ressi6f0644e2018-07-06 10:05:32 +020021from tempest.lib import exceptions
Jakub Libosvar7c58cb22017-05-03 09:00:14 +000022
Chandan Kumar667d3d32017-09-22 12:24:06 +053023from neutron_tempest_plugin import config
Jakub Libosvar7c58cb22017-05-03 09:00:14 +000024
25
Federico Ressie9c89bf2018-04-19 13:02:33 +020026CONF = config.CONF
27LOG = log.getLogger(__name__)
28
29
Jakub Libosvar7c58cb22017-05-03 09:00:14 +000030class Client(ssh.Client):
Federico Ressie9c89bf2018-04-19 13:02:33 +020031
Federico Ressi6f0644e2018-07-06 10:05:32 +020032 default_ssh_lang = 'en_US.UTF-8'
33
Federico Ressie9c89bf2018-04-19 13:02:33 +020034 timeout = CONF.validation.ssh_timeout
35
36 proxy_jump_host = CONF.neutron_plugin_options.ssh_proxy_jump_host
37 proxy_jump_username = CONF.neutron_plugin_options.ssh_proxy_jump_username
38 proxy_jump_password = CONF.neutron_plugin_options.ssh_proxy_jump_password
39 proxy_jump_keyfile = CONF.neutron_plugin_options.ssh_proxy_jump_keyfile
40 proxy_jump_port = CONF.neutron_plugin_options.ssh_proxy_jump_port
41
42 def __init__(self, host, username, password=None, timeout=None, pkey=None,
43 channel_timeout=10, look_for_keys=False, key_filename=None,
44 port=22, proxy_client=None):
45
46 timeout = timeout or self.timeout
47
48 if self.proxy_jump_host:
49 # Perform all SSH connections passing through configured SSH server
50 proxy_client = proxy_client or self.create_proxy_client(
51 timeout=timeout, channel_timeout=channel_timeout)
52
53 super(Client, self).__init__(
54 host=host, username=username, password=password, timeout=timeout,
55 pkey=pkey, channel_timeout=channel_timeout,
56 look_for_keys=look_for_keys, key_filename=key_filename, port=port,
57 proxy_client=proxy_client)
58
59 @classmethod
60 def create_proxy_client(cls, look_for_keys=True, **kwargs):
61 host = cls.proxy_jump_host
62 if not host:
63 # proxy_jump_host string cannot be empty or None
64 raise ValueError(
65 "'proxy_jump_host' configuration option is empty.")
66
67 # Let accept an empty string as a synonymous of default value on below
68 # options
69 password = cls.proxy_jump_password or None
70 key_file = cls.proxy_jump_keyfile or None
71 username = cls.proxy_jump_username
72
73 # Port must be a positive integer
74 port = cls.proxy_jump_port
75 if port <= 0 or port > 65535:
76 raise ValueError(
77 "Invalid value for 'proxy_jump_port' configuration option: "
78 "{!r}".format(port))
79
80 login = "{username}@{host}:{port}".format(username=username, host=host,
81 port=port)
82
83 if key_file:
84 # expand ~ character with user HOME directory
85 key_file = os.path.expanduser(key_file)
86 if os.path.isfile(key_file):
87 LOG.debug("Going to create SSH connection to %r using key "
88 "file: %s", login, key_file)
89
90 else:
91 # This message could help the user to identify a
92 # mis-configuration in tempest.conf
93 raise ValueError(
94 "Cannot find file specified as 'proxy_jump_keyfile' "
95 "option: {!r}".format(key_file))
96
97 elif password:
98 LOG.debug("Going to create SSH connection to %r using password.",
99 login)
100
101 elif look_for_keys:
102 # This message could help the user to identify a mis-configuration
103 # in tempest.conf
104 LOG.info("Both 'proxy_jump_password' and 'proxy_jump_keyfile' "
105 "options are empty. Going to create SSH connection to %r "
106 "looking for key file location into %r directory.",
107 login, os.path.expanduser('~/.ssh'))
108 else:
109 # An user that forces look_for_keys=False should really know what
110 # he really wants
111 LOG.warning("No authentication method provided to create an SSH "
112 "connection to %r. If it fails, then please "
113 "set 'proxy_jump_keyfile' to provide a valid SSH key "
114 "file.", login)
115
116 return ssh.Client(
117 host=host, username=username, password=password,
118 look_for_keys=look_for_keys, key_filename=key_file,
119 port=port, proxy_client=None, **kwargs)
Federico Ressi6f0644e2018-07-06 10:05:32 +0200120
121 # attribute used to keep reference to opened client connection
122 _client = None
123
124 def connect(self, *args, **kwargs):
125 """Creates paramiko.SSHClient and connect it to remote SSH server
126
127 In case this method is called more times it returns the same client
128 and no new SSH connection is created until close method is called.
129
130 :returns: paramiko.Client connected to remote server.
131
132 :raises tempest.lib.exceptions.SSHTimeout: in case it fails to connect
133 to remote server.
134 """
135 client = self._client
136 if client is None:
137 client = super(Client, self)._get_ssh_connection(
138 *args, **kwargs)
139 self._client = client
140
141 return client
142
143 # This overrides superclass protected method to make sure exec_command
144 # method is going to reuse the same SSH client and connection if called
145 # more times
146 _get_ssh_connection = connect
147
148 def close(self):
149 """Closes connection to SSH server and cleanup resources.
150 """
151 client = self._client
152 if client is not None:
153 client.close()
154 self._client = None
155
156 def open_session(self):
157 """Gets connection to SSH server and open a new paramiko.Channel
158
159 :returns: new paramiko.Channel
160 """
161
162 client = self.connect()
163
164 try:
165 return client.get_transport().open_session()
166 except paramiko.SSHException:
167 # the request is rejected, the session ends prematurely or
168 # there is a timeout opening a channel
169 LOG.exception("Unable to open SSH session")
170 raise exceptions.SSHTimeout(host=self.host,
171 user=self.username,
172 password=self.password)
173
174 def execute_script(self, script, become_root=False,
175 combine_stderr=True, shell='sh -eux'):
176 """Connect to remote machine and executes script.
177
178 Implementation note: it passes script lines to shell interpreter via
179 STDIN. Therefore script line number could be not available to some
180 script interpreters for debugging porposes.
181
182 :param script: script lines to be executed.
183
184 :param become_root: executes interpreter as root with sudo.
185
186 :param combine_stderr (bool): whenever to redirect STDERR to STDOUT so
187 that output from both streams are returned together. True by default.
188
189 :param shell: command line used to launch script interpreter. By
190 default it executes Bash with -eux options enabled. This means that
191 any command returning non-zero exist status or any any undefined
192 variable would interrupt script execution with an error and every
193 command executed by the script is going to be traced to STDERR.
194
195 :returns output written by script to STDOUT.
196
197 :raises tempest.lib.exceptions.SSHTimeout: in case it fails to connect
198 to remote server or it fails to open a channel.
199
200 :raises tempest.lib.exceptions.SSHExecCommandFailed: in case command
201 script exits with non zero exit status.
202 """
203
204 channel = self.open_session()
205 with channel:
206
207 # Combine STOUT and STDERR to have to handle with only one stream
208 channel.set_combine_stderr(combine_stderr)
209
210 # Set default environment
211 channel.update_environment({
212 # Language and encoding
213 'LC_ALL': os.environ.get('LC_ALL') or self.default_ssh_lang,
214 'LANG': os.environ.get('LANG') or self.default_ssh_lang
215 })
216
217 if become_root:
218 shell = 'sudo ' + shell
219 # Spawn a Bash
220 channel.exec_command(shell)
221
222 lines_iterator = iter(script.splitlines())
223 output_data = b''
224 error_data = b''
225
226 while not channel.exit_status_ready():
227 # Drain incoming data buffers
228 while channel.recv_ready():
229 output_data += channel.recv(self.buf_size)
230 while channel.recv_stderr_ready():
231 error_data += channel.recv_stderr(self.buf_size)
232
233 if channel.send_ready():
234 try:
235 line = next(lines_iterator)
236 except StopIteration:
237 # Finalize Bash script execution
238 channel.shutdown_write()
239 else:
240 # Send script to Bash STDIN line by line
241 channel.send((line + '\n').encode('utf-8'))
242 else:
243 time.sleep(.1)
244
245 # Get exit status and drain incoming data buffers
246 exit_status = channel.recv_exit_status()
247 while channel.recv_ready():
248 output_data += channel.recv(self.buf_size)
249 while channel.recv_stderr_ready():
250 error_data += channel.recv_stderr(self.buf_size)
251
252 if exit_status != 0:
253 raise exceptions.SSHExecCommandFailed(
254 command='bash', exit_status=exit_status,
255 stderr=error_data.decode('utf-8'),
256 stdout=output_data.decode('utf-8'))
257
258 return output_data.decode('utf-8')