blob: 4829db27292b2362afe9b413ba3855f7d0de3c6a [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):
Brian Haleyaee61ac2018-10-09 20:00:27 -0400149 """Closes connection to SSH server and cleanup resources."""
Federico Ressi6f0644e2018-07-06 10:05:32 +0200150 client = self._client
151 if client is not None:
152 client.close()
153 self._client = None
154
155 def open_session(self):
156 """Gets connection to SSH server and open a new paramiko.Channel
157
158 :returns: new paramiko.Channel
159 """
160
161 client = self.connect()
162
163 try:
164 return client.get_transport().open_session()
165 except paramiko.SSHException:
166 # the request is rejected, the session ends prematurely or
167 # there is a timeout opening a channel
168 LOG.exception("Unable to open SSH session")
169 raise exceptions.SSHTimeout(host=self.host,
170 user=self.username,
171 password=self.password)
172
173 def execute_script(self, script, become_root=False,
174 combine_stderr=True, shell='sh -eux'):
175 """Connect to remote machine and executes script.
176
177 Implementation note: it passes script lines to shell interpreter via
178 STDIN. Therefore script line number could be not available to some
179 script interpreters for debugging porposes.
180
181 :param script: script lines to be executed.
182
183 :param become_root: executes interpreter as root with sudo.
184
185 :param combine_stderr (bool): whenever to redirect STDERR to STDOUT so
186 that output from both streams are returned together. True by default.
187
188 :param shell: command line used to launch script interpreter. By
189 default it executes Bash with -eux options enabled. This means that
190 any command returning non-zero exist status or any any undefined
191 variable would interrupt script execution with an error and every
192 command executed by the script is going to be traced to STDERR.
193
194 :returns output written by script to STDOUT.
195
196 :raises tempest.lib.exceptions.SSHTimeout: in case it fails to connect
197 to remote server or it fails to open a channel.
198
199 :raises tempest.lib.exceptions.SSHExecCommandFailed: in case command
200 script exits with non zero exit status.
201 """
202
203 channel = self.open_session()
204 with channel:
205
206 # Combine STOUT and STDERR to have to handle with only one stream
207 channel.set_combine_stderr(combine_stderr)
208
209 # Set default environment
210 channel.update_environment({
211 # Language and encoding
212 'LC_ALL': os.environ.get('LC_ALL') or self.default_ssh_lang,
213 'LANG': os.environ.get('LANG') or self.default_ssh_lang
214 })
215
216 if become_root:
217 shell = 'sudo ' + shell
218 # Spawn a Bash
219 channel.exec_command(shell)
220
221 lines_iterator = iter(script.splitlines())
222 output_data = b''
223 error_data = b''
224
225 while not channel.exit_status_ready():
226 # Drain incoming data buffers
227 while channel.recv_ready():
228 output_data += channel.recv(self.buf_size)
229 while channel.recv_stderr_ready():
230 error_data += channel.recv_stderr(self.buf_size)
231
232 if channel.send_ready():
233 try:
234 line = next(lines_iterator)
235 except StopIteration:
236 # Finalize Bash script execution
237 channel.shutdown_write()
238 else:
239 # Send script to Bash STDIN line by line
240 channel.send((line + '\n').encode('utf-8'))
241 else:
242 time.sleep(.1)
243
244 # Get exit status and drain incoming data buffers
245 exit_status = channel.recv_exit_status()
246 while channel.recv_ready():
247 output_data += channel.recv(self.buf_size)
248 while channel.recv_stderr_ready():
249 error_data += channel.recv_stderr(self.buf_size)
250
251 if exit_status != 0:
252 raise exceptions.SSHExecCommandFailed(
253 command='bash', exit_status=exit_status,
254 stderr=error_data.decode('utf-8'),
255 stdout=output_data.decode('utf-8'))
256
257 return output_data.decode('utf-8')