blob: 7ab2ea5de959b84cb17e7b97ac9a945668fe6135 [file] [log] [blame]
Alex0989ecf2022-03-29 13:43:21 -05001# Author: Alex Savatieiev (osavatieiev@mirantis.com; a.savex@gmail.com)
2# Copyright 2019-2022 Mirantis, Inc.
Alex9a4ad212020-10-01 18:04:25 -05003import queue
4import subprocess
5import traceback
6import threading
7
8from time import sleep
9from .exception import TimeoutException, CheckerException
10from .other import shell, piped_shell
11from .log import logger, logger_cli
12
13
14# We do not use paramiko here to preserve system level ssh config
15def ssh_shell_p(
16 command,
17 host,
18 username=None,
19 keypath=None,
20 port=None,
21 silent=False,
22 piped=False,
23 use_sudo=False
24):
25 _ssh_cmd = []
26 _ssh_cmd.append("ssh")
27 if silent:
28 _ssh_cmd.append("-q")
29 # Build SSH cmd
30 if keypath:
31 _ssh_cmd.append("-i " + keypath)
Alexc4f59622021-08-27 13:42:00 -050032 _ssh_cmd.append("-o " + "IdentitiesOnly=yes")
Alex9a4ad212020-10-01 18:04:25 -050033 if port:
34 _ssh_cmd.append("-p " + str(port))
35 if username:
36 _ssh_cmd.append(username+'@'+host)
37 else:
38 _ssh_cmd.append(host)
39
40 if use_sudo:
41 _ssh_cmd.append("sudo")
42
43 _ssh_cmd.append(command)
44
45 _ssh_cmd = " ".join(_ssh_cmd)
46 if not piped:
47 return shell(_ssh_cmd)
48 else:
49 return piped_shell(_ssh_cmd)
50
51
52def scp_p(
53 source,
54 target,
55 port=None,
56 keypath=None,
57 silent=False,
58 piped=False
59):
60 _scp_cmd = []
61 _scp_cmd.append("scp")
62 if port:
63 _scp_cmd.append("-P " + str(port))
64 if silent:
65 _scp_cmd.append("-q")
66 # Build SSH cmd
67 if keypath:
68 _scp_cmd.append("-i " + keypath)
69 _scp_cmd.append(source)
70 _scp_cmd.append(target)
71 _scp_cmd = " ".join(_scp_cmd)
72 if not piped:
73 return shell(_scp_cmd)
74 else:
75 return piped_shell(_scp_cmd)
76
77
78def output_reader(_stdout, outq):
79 for line in iter(_stdout.readline, b''):
80 outq.put(line.decode('utf-8'))
81
82
83# Base class for all SSH related actions
84class SshBase(object):
85 def __init__(
86 self,
87 tgt_host,
88 user=None,
89 keypath=None,
90 port=None,
91 timeout=15,
92 silent=False,
93 piped=False
94 ):
95 self._cmd = ["ssh"]
96 self.timeout = timeout
97 self.port = port if port else 22
98 self.host = tgt_host
99 self.username = user
100 self.keypath = keypath
101 self.silent = silent
102 self.piped = piped
103 self.output = []
104
105 self._options = ["-tt"]
106 if self.keypath:
107 self._options += ["-i", self.keypath]
108 if self.port:
109 self._options += ["-p", str(self.port)]
110 self._extra_options = [
111 "-o", "UserKnownHostsFile=/dev/null",
Alexc4f59622021-08-27 13:42:00 -0500112 "-o", "StrictHostKeyChecking=no",
113 "-o", "IdentitiesOnly=yes"
Alex9a4ad212020-10-01 18:04:25 -0500114 ]
115
116 self._host_uri = ""
117 if self.username:
118 self._host_uri = self.username + "@" + self.host
119 else:
120 self._host_uri = self.host
121
122 def _connect(self, banner="Welcome"):
123 if not isinstance(banner, str):
124 raise CheckerException(
125 "Invalid SSH banner type: '{}'".format(type(banner))
126 )
Alexc4f59622021-08-27 13:42:00 -0500127 logger.debug("... connecting")
Alex9a4ad212020-10-01 18:04:25 -0500128 while True:
129 try:
130 line = self.outq.get(block=False)
131 self.output.append(line)
132 if line.startswith(banner):
133 break
134 except queue.Empty:
135 logger.debug("... {} sec".format(self.timeout))
136 sleep(1)
137 self.timeout -= 1
138 if not self.timeout:
139 logger.debug(
140 "...timed out after {} sec".format(str(self.timeout))
141 )
142 return False
Alexc4f59622021-08-27 13:42:00 -0500143 logger.debug("... connected")
Alex9a4ad212020-10-01 18:04:25 -0500144 return True
145
146 def _wait_for_string(self, string):
Alexc4f59622021-08-27 13:42:00 -0500147 logger.debug("... waiting for '{}'".format(string))
Alex9a4ad212020-10-01 18:04:25 -0500148 while True:
149 try:
150 line = self.outq.get(block=False)
151 line = line.decode() if isinstance(line, bytes) else line
152 self.output.append(line)
153 if not line.startswith(string):
154 continue
155 else:
156 break
157 except queue.Empty:
158 logger.debug("... {} sec".format(self.timeout))
159 sleep(1)
160 self.timeout -= 1
161 if not self.timeout:
162 logger.debug(
Alexc4f59622021-08-27 13:42:00 -0500163 "... timed out after {} sec".format(str(self.timeout))
Alex9a4ad212020-10-01 18:04:25 -0500164 )
165 return False
Alexc4f59622021-08-27 13:42:00 -0500166 logger.debug("... found")
Alex9a4ad212020-10-01 18:04:25 -0500167 return True
168
169 def _init_connection(self, cmd):
170 self._proc = subprocess.Popen(
171 cmd,
172 stdin=subprocess.PIPE,
173 stdout=subprocess.PIPE,
174 stderr=subprocess.PIPE,
175 universal_newlines=False,
176 bufsize=0
177 )
178 # Create thread safe output getter
179 self.outq = queue.Queue()
180 self._t = threading.Thread(
181 target=output_reader,
182 args=(self._proc.stdout, self.outq)
183 )
184 self._t.start()
185
186 # Track if there is an yes/no
187 if not self._connect():
188 raise TimeoutException(
189 "SSH connection to '{}'".format(self.host)
190 )
191
192 self.input = self._proc.stdin
193 self.get_output()
194 logger.debug(
195 "Connected. Banners:\n{}".format(
196 "".join(self.flush_output())
197 )
198 )
199
200 def _end_connection(self):
201 # Kill the ssh process if it is alive
202 if self._proc.poll() is None:
203 self._proc.kill()
204 self.get_output()
205
206 return
207
208 def do(self, cmd, timeout=30, sudo=False, strip_cmd=True):
209 cmd = cmd if isinstance(cmd, bytes) else bytes(cmd.encode('utf-8'))
Alexc4f59622021-08-27 13:42:00 -0500210 logger.debug("... ssh: '{}'".format(cmd))
Alex9a4ad212020-10-01 18:04:25 -0500211 if sudo:
212 _cmd = b"sudo " + cmd
213 else:
214 _cmd = cmd
215 # run command
216 self.input.write(_cmd + b'\n')
217 # wait for completion
218 self.wait_ready(_cmd, timeout=timeout)
219 self.get_output()
220 _output = self.flush_output().replace('\r', '')
221 if strip_cmd:
222 return "\n".join(_output.splitlines()[1:])
223 else:
224 return _output
225
226 def get_output(self):
227 while True:
228 try:
229 line = self.outq.get(block=False)
230 line = str(line) if isinstance(line, bytes) else line
231 self.output.append(line)
232 except queue.Empty:
233 return self.output
234 return None
235
236 def flush_output(self, as_string=True):
237 _out = self.output
238 self.output = []
239 if as_string:
240 return "".join(_out)
241 else:
242 return _out
243
244 def wait_ready(self, cmd, timeout=60):
Alexb78191f2021-11-02 16:35:46 -0500245 # Wait for command to finish inside SSH
Alex9a4ad212020-10-01 18:04:25 -0500246 def _strip_cmd_carrets(_str, carret='\r', skip_chars=1):
247 _cnt = _str.count(carret)
248 while _cnt > 0:
249 _idx = _str.index(carret)
250 _str = _str[:_idx] + _str[_idx+1+skip_chars:]
251 _cnt -= 1
252 return _str
253 while True:
254 try:
255 _line = self.outq.get(block=False)
256 line = _line.decode() if isinstance(_line, bytes) else _line
257 # line = line.replace('\r', '')
258 self.output.append(line)
259 # check if this is the command itself and skip
260 if '$' in line:
261 _cmd = line.split('$', 1)[1].strip()
262 _cmd = _strip_cmd_carrets(_cmd)
263 if _cmd == cmd.decode():
264 continue
265 break
266 except queue.Empty:
267 logger.debug("... {} sec".format(timeout))
268 sleep(1)
269 timeout -= 1
270 if not timeout:
271 logger.debug("...timed out")
272 return False
273 return True
274
275 def wait_for_string(self, string, timeout=60):
276 if not self._wait_for_string(string):
277 raise TimeoutException(
278 "Time out waiting for string '{}'".format(string)
279 )
280 else:
281 return True
282
283
284class SshShell(SshBase):
285 def __enter__(self):
286 self._cmd = ["ssh"]
287 self._cmd += self._options
288 self._cmd += self._extra_options
289 self._cmd += [self._host_uri]
290
291 logger.debug("...shell to: '{}'".format(" ".join(self._cmd)))
292 self._init_connection(self._cmd)
293 return self
294
295 def __exit__(self, _type, _value, _traceback):
296 self._end_connection()
297 if _value:
298 logger.warn(
299 "Error running SSH:\r\n{}".format(
300 "".join(traceback.format_exception(
301 _type,
302 _value,
303 _traceback
304 ))
305 )
306 )
307
308 return True
309
310 def connect(self):
311 return self.__enter__()
312
313 def kill(self):
314 self._end_connection()
315
316 def get_host_path(self, path):
317 _uri = self.host + ":" + path
318 if self.username:
319 _uri = self.username + "@" + _uri
320 return _uri
321
322 def scp(self, _src, _dst):
323 self._scp_options = []
324 if self.keypath:
325 self._scp_options += ["-i", self.keypath]
326 if self.port:
327 self._scp_options += ["-P", str(self.port)]
328
329 _cmd = ["scp"]
330 _cmd += self._scp_options
331 _cmd += self._extra_options
332 _cmd += [_src]
333 _cmd += [_dst]
334
335 logger.debug("...scp: '{}'".format(" ".join(_cmd)))
336 _proc = subprocess.Popen(
337 _cmd,
338 stdout=subprocess.PIPE,
339 stderr=subprocess.PIPE
340 )
341 _r = _proc.communicate()
342 _e = _r[1].decode() if _r[1] else ""
343 return _proc.returncode, _r[0].decode(), _e
344
345
346class PortForward(SshBase):
347 def __init__(
348 self,
349 host,
350 fwd_host,
351 user=None,
352 keypath=None,
353 port=None,
354 loc_port=10022,
355 fwd_port=22,
356 timeout=15
357 ):
358 super(PortForward, self).__init__(
359 host,
360 user=user,
361 keypath=keypath,
362 port=port,
363 timeout=timeout,
364 silent=True,
365 piped=False
366 )
367 self.f_host = fwd_host
368 self.l_port = loc_port
369 self.f_port = fwd_port
370
371 self._forward_options = [
372 "-L",
373 ":".join([str(self.l_port), self.f_host, str(self.f_port)])
374 ]
375
376 def __enter__(self):
377 self._cmd = ["ssh"]
378 self._cmd += self._forward_options
379 self._cmd += self._options
380 self._cmd += self._extra_options
381 self._cmd += [self._host_uri]
382
383 logger.debug(
Alexc4f59622021-08-27 13:42:00 -0500384 "... port forwarding: '{}'".format(" ".join(self._cmd))
Alex9a4ad212020-10-01 18:04:25 -0500385 )
386 self._init_connection(self._cmd)
387 return self
388
389 def __exit__(self, _type, _value, _traceback):
390 self._end_connection()
391 if _value:
392 logger_cli.warn(
393 "Error running SSH:\r\n{}".format(
394 "".join(traceback.format_exception(
395 _type,
396 _value,
397 _traceback
398 ))
399 )
400 )
401
402 return True
403
404 def connect(self):
405 return self.__enter__()
406
407 def kill(self):
408 self._end_connection()