859 lines
28 KiB
Python
859 lines
28 KiB
Python
# Licensed to the Apache Software Foundation (ASF) under one or more
|
|
# contributor license agreements. See the NOTICE file distributed with
|
|
# this work for additional information regarding copyright ownership.
|
|
# The ASF licenses this file to You under the Apache License, Version 2.0
|
|
# (the "License"); you may not use this file except in compliance with
|
|
# the License. You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
"""
|
|
Wraps multiple ways to communicate over SSH.
|
|
"""
|
|
|
|
from typing import Type
|
|
from typing import Optional
|
|
from typing import Tuple
|
|
from typing import List
|
|
from typing import Union
|
|
from typing import cast
|
|
|
|
have_paramiko = False
|
|
|
|
try:
|
|
import paramiko
|
|
have_paramiko = True
|
|
except ImportError:
|
|
pass
|
|
|
|
# Depending on your version of Paramiko, it may cause a deprecation
|
|
# warning on Python 2.6.
|
|
# Ref: https://bugs.launchpad.net/paramiko/+bug/392973
|
|
|
|
import os
|
|
import re
|
|
import time
|
|
import subprocess
|
|
import logging
|
|
import warnings
|
|
|
|
from os.path import split as psplit
|
|
from os.path import join as pjoin
|
|
|
|
from libcloud.utils.logging import ExtraLogFormatter
|
|
from libcloud.utils.py3 import StringIO
|
|
from libcloud.utils.py3 import b
|
|
|
|
__all__ = [
|
|
'BaseSSHClient',
|
|
'ParamikoSSHClient',
|
|
'ShellOutSSHClient',
|
|
|
|
'SSHCommandTimeoutError'
|
|
]
|
|
|
|
SUPPORTED_KEY_TYPES_URL = 'https://libcloud.readthedocs.io/en/latest/compute/deployment.html#supported-private-ssh-key-types' # NOQA
|
|
|
|
|
|
class SSHCommandTimeoutError(Exception):
|
|
"""
|
|
Exception which is raised when an SSH command times out.
|
|
"""
|
|
def __init__(self, cmd, timeout, stdout=None, stderr=None):
|
|
# type: (str, float, Optional[str], Optional[str]) -> None
|
|
self.cmd = cmd
|
|
self.timeout = timeout
|
|
self.stdout = stdout
|
|
self.stderr = stderr
|
|
|
|
self.message = 'Command didn\'t finish in %s seconds' % (timeout)
|
|
super(SSHCommandTimeoutError, self).__init__(self.message)
|
|
|
|
def __repr__(self):
|
|
return ('<SSHCommandTimeoutError: cmd="%s",timeout=%s)>' %
|
|
(self.cmd, self.timeout))
|
|
|
|
def __str__(self):
|
|
return self.__repr__()
|
|
|
|
|
|
class BaseSSHClient(object):
|
|
"""
|
|
Base class representing a connection over SSH/SCP to a remote node.
|
|
"""
|
|
|
|
def __init__(self,
|
|
hostname, # type: str
|
|
port=22, # type: int
|
|
username='root', # type: str
|
|
password=None, # type: Optional[str]
|
|
key=None, # type: Optional[str]
|
|
key_files=None, # type: Optional[Union[str, List[str]]]
|
|
timeout=None # type: Optional[float]
|
|
):
|
|
"""
|
|
:type hostname: ``str``
|
|
:keyword hostname: Hostname or IP address to connect to.
|
|
|
|
:type port: ``int``
|
|
:keyword port: TCP port to communicate on, defaults to 22.
|
|
|
|
:type username: ``str``
|
|
:keyword username: Username to use, defaults to root.
|
|
|
|
:type password: ``str``
|
|
:keyword password: Password to authenticate with or a password used
|
|
to unlock a private key if a password protected key
|
|
is used.
|
|
|
|
:param key: Deprecated in favor of ``key_files`` argument.
|
|
|
|
:type key_files: ``str`` or ``list``
|
|
:keyword key_files: A list of paths to the private key files to use.
|
|
"""
|
|
if key is not None:
|
|
message = ('You are using deprecated "key" argument which has '
|
|
'been replaced with "key_files" argument')
|
|
warnings.warn(message, DeprecationWarning)
|
|
|
|
# key_files has precedent
|
|
key_files = key if not key_files else key_files
|
|
|
|
self.hostname = hostname
|
|
self.port = port
|
|
self.username = username
|
|
self.password = password
|
|
self.key_files = key_files
|
|
self.timeout = timeout
|
|
|
|
def connect(self):
|
|
# type: () -> bool
|
|
"""
|
|
Connect to the remote node over SSH.
|
|
|
|
:return: True if the connection has been successfully established,
|
|
False otherwise.
|
|
:rtype: ``bool``
|
|
"""
|
|
raise NotImplementedError(
|
|
'connect not implemented for this ssh client')
|
|
|
|
def put(self, path, contents=None, chmod=None, mode='w'):
|
|
# type: (str, Optional[Union[str, bytes]], Optional[int], str) -> str
|
|
"""
|
|
Upload a file to the remote node.
|
|
|
|
:type path: ``str``
|
|
:keyword path: File path on the remote node.
|
|
|
|
:type contents: ``str``
|
|
:keyword contents: File Contents.
|
|
|
|
:type chmod: ``int``
|
|
:keyword chmod: chmod file to this after creation.
|
|
|
|
:type mode: ``str``
|
|
:keyword mode: Mode in which the file is opened.
|
|
|
|
:return: Full path to the location where a file has been saved.
|
|
:rtype: ``str``
|
|
"""
|
|
raise NotImplementedError(
|
|
'put not implemented for this ssh client')
|
|
|
|
def putfo(self, path, fo=None, chmod=None):
|
|
"""
|
|
Upload file like object to the remote server.
|
|
|
|
:param path: Path to upload the file to.
|
|
:type path: ``str``
|
|
|
|
:param fo: File like object to read the content from.
|
|
:type fo: File handle or file like object.
|
|
|
|
:type chmod: ``int``
|
|
:keyword chmod: chmod file to this after creation.
|
|
|
|
:return: Full path to the location where a file has been saved.
|
|
:rtype: ``str``
|
|
"""
|
|
raise NotImplementedError(
|
|
'putfo not implemented for this ssh client')
|
|
|
|
def delete(self, path):
|
|
# type: (str) -> bool
|
|
"""
|
|
Delete/Unlink a file on the remote node.
|
|
|
|
:type path: ``str``
|
|
:keyword path: File path on the remote node.
|
|
|
|
:return: True if the file has been successfully deleted, False
|
|
otherwise.
|
|
:rtype: ``bool``
|
|
"""
|
|
raise NotImplementedError(
|
|
'delete not implemented for this ssh client')
|
|
|
|
def run(self, cmd, timeout=None):
|
|
# type: (str, Optional[float]) -> Tuple[str, str, int]
|
|
"""
|
|
Run a command on a remote node.
|
|
|
|
:type cmd: ``str``
|
|
:keyword cmd: Command to run.
|
|
|
|
:return ``list`` of [stdout, stderr, exit_status]
|
|
"""
|
|
raise NotImplementedError(
|
|
'run not implemented for this ssh client')
|
|
|
|
def close(self):
|
|
# type: () -> bool
|
|
"""
|
|
Shutdown connection to the remote node.
|
|
|
|
:return: True if the connection has been successfully closed, False
|
|
otherwise.
|
|
:rtype: ``bool``
|
|
"""
|
|
raise NotImplementedError(
|
|
'close not implemented for this ssh client')
|
|
|
|
def _get_and_setup_logger(self):
|
|
# type: () -> logging.Logger
|
|
logger = logging.getLogger('libcloud.compute.ssh')
|
|
path = os.getenv('LIBCLOUD_DEBUG')
|
|
|
|
if path:
|
|
handler = logging.FileHandler(path)
|
|
handler.setFormatter(ExtraLogFormatter())
|
|
logger.addHandler(handler)
|
|
logger.setLevel(logging.DEBUG)
|
|
|
|
return logger
|
|
|
|
|
|
class ParamikoSSHClient(BaseSSHClient):
|
|
"""
|
|
A SSH Client powered by Paramiko.
|
|
"""
|
|
|
|
# Maximum number of bytes to read at once from a socket
|
|
CHUNK_SIZE = 4096
|
|
|
|
# How long to sleep while waiting for command to finish (to prevent busy
|
|
# waiting)
|
|
SLEEP_DELAY = 0.2
|
|
|
|
def __init__(self,
|
|
hostname, # type: str
|
|
port=22, # type: int
|
|
username='root', # type: str
|
|
password=None, # type: Optional[str]
|
|
key=None, # type: Optional[str]
|
|
key_files=None, # type: Optional[Union[str, List[str]]]
|
|
key_material=None, # type: Optional[str]
|
|
timeout=None, # type: Optional[float]
|
|
keep_alive=None, # type: Optional[int]
|
|
use_compression=False # type: bool
|
|
):
|
|
"""
|
|
Authentication is always attempted in the following order:
|
|
|
|
- The key passed in (if key is provided)
|
|
- Any key we can find through an SSH agent (only if no password and
|
|
key is provided)
|
|
- Any "id_rsa" or "id_dsa" key discoverable in ~/.ssh/ (only if no
|
|
password and key is provided)
|
|
- Plain username/password auth, if a password was given (if password is
|
|
provided)
|
|
|
|
:param keep_alive: Optional keep alive internal (in seconds) to use.
|
|
:type keep_alive: ``int``
|
|
|
|
:param use_compression: True to use compression.
|
|
:type use_compression: ``bool``
|
|
"""
|
|
if key_files and key_material:
|
|
raise ValueError(('key_files and key_material arguments are '
|
|
'mutually exclusive'))
|
|
|
|
super(ParamikoSSHClient, self).__init__(hostname=hostname, port=port,
|
|
username=username,
|
|
password=password,
|
|
key=key,
|
|
key_files=key_files,
|
|
timeout=timeout)
|
|
|
|
self.key_material = key_material
|
|
self.keep_alive = keep_alive
|
|
self.use_compression = use_compression
|
|
|
|
self.client = paramiko.SSHClient()
|
|
self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
self.logger = self._get_and_setup_logger()
|
|
|
|
# This object is lazily created on first SFTP operation (e.g. put()
|
|
# method call)
|
|
self.sftp_client = None
|
|
|
|
def connect(self):
|
|
conninfo = {'hostname': self.hostname,
|
|
'port': self.port,
|
|
'username': self.username,
|
|
'allow_agent': False,
|
|
'look_for_keys': False}
|
|
|
|
if self.password:
|
|
conninfo['password'] = self.password
|
|
|
|
if self.key_files:
|
|
conninfo['key_filename'] = self.key_files
|
|
|
|
if self.key_material:
|
|
conninfo['pkey'] = self._get_pkey_object(
|
|
key=self.key_material,
|
|
password=self.password)
|
|
|
|
if not self.password and not (self.key_files or self.key_material):
|
|
conninfo['allow_agent'] = True
|
|
conninfo['look_for_keys'] = True
|
|
|
|
if self.timeout:
|
|
conninfo['timeout'] = self.timeout
|
|
|
|
# This is a workaround for paramiko only supporting key files in
|
|
# format staring with "BEGIN RSA PRIVATE KEY".
|
|
# If key_files are provided and a key looks like a PEM formatted key
|
|
# we try to convert it into a format supported by paramiko
|
|
if (self.key_files and not isinstance(self.key_files, (list, tuple))
|
|
and os.path.isfile(self.key_files)):
|
|
with open(self.key_files, 'r') as fp:
|
|
key_material = fp.read()
|
|
|
|
try:
|
|
pkey = self._get_pkey_object(key=key_material,
|
|
password=self.password)
|
|
except paramiko.ssh_exception.PasswordRequiredException as e:
|
|
raise e
|
|
except Exception:
|
|
pass
|
|
else:
|
|
# It appears key is valid, but it was passed in in an invalid
|
|
# format. Try to use the converted key directly
|
|
del conninfo['key_filename']
|
|
conninfo['pkey'] = pkey
|
|
|
|
extra = {'_hostname': self.hostname, '_port': self.port,
|
|
'_username': self.username, '_timeout': self.timeout}
|
|
|
|
if self.password:
|
|
extra['_auth_method'] = 'password'
|
|
else:
|
|
extra['_auth_method'] = 'key_file'
|
|
|
|
if self.key_files:
|
|
extra['_key_file'] = self.key_files
|
|
|
|
self.logger.debug('Connecting to server', extra=extra)
|
|
|
|
self.client.connect(**conninfo)
|
|
return True
|
|
|
|
def put(self, path, contents=None, chmod=None, mode='w'):
|
|
extra = {'_path': path, '_mode': mode, '_chmod': chmod}
|
|
self.logger.debug('Uploading file', extra=extra)
|
|
|
|
sftp = self._get_sftp_client()
|
|
|
|
# less than ideal, but we need to mkdir stuff otherwise file() fails
|
|
head, tail = psplit(path)
|
|
|
|
if path[0] == "/":
|
|
sftp.chdir("/")
|
|
else:
|
|
# Relative path - start from a home directory (~)
|
|
sftp.chdir('.')
|
|
|
|
for part in head.split("/"):
|
|
if part != "":
|
|
try:
|
|
sftp.mkdir(part)
|
|
except IOError:
|
|
# so, there doesn't seem to be a way to
|
|
# catch EEXIST consistently *sigh*
|
|
pass
|
|
sftp.chdir(part)
|
|
|
|
cwd = sftp.getcwd()
|
|
cwd = self._sanitize_cwd(cwd=cwd)
|
|
|
|
ak = sftp.file(tail, mode=mode)
|
|
ak.write(contents)
|
|
if chmod is not None:
|
|
ak.chmod(chmod)
|
|
ak.close()
|
|
|
|
file_path = self._sanitize_file_path(cwd=cwd, file_path=path)
|
|
return file_path
|
|
|
|
def putfo(self, path, fo=None, chmod=None):
|
|
"""
|
|
Upload file like object to the remote server.
|
|
|
|
Unlike put(), this method operates on file objects and not directly on
|
|
file content which makes it much more efficient for large files since
|
|
it utilizes pipelining.
|
|
"""
|
|
extra = {'_path': path, '_chmod': chmod}
|
|
self.logger.debug('Uploading file', extra=extra)
|
|
|
|
sftp = self._get_sftp_client()
|
|
|
|
# less than ideal, but we need to mkdir stuff otherwise file() fails
|
|
head, tail = psplit(path)
|
|
|
|
if path[0] == "/":
|
|
sftp.chdir("/")
|
|
else:
|
|
# Relative path - start from a home directory (~)
|
|
sftp.chdir('.')
|
|
|
|
for part in head.split("/"):
|
|
if part != "":
|
|
try:
|
|
sftp.mkdir(part)
|
|
except IOError:
|
|
# so, there doesn't seem to be a way to
|
|
# catch EEXIST consistently *sigh*
|
|
pass
|
|
sftp.chdir(part)
|
|
|
|
cwd = sftp.getcwd()
|
|
cwd = self._sanitize_cwd(cwd=cwd)
|
|
|
|
sftp.putfo(fo, path)
|
|
if chmod is not None:
|
|
ak = sftp.file(tail)
|
|
ak.chmod(chmod)
|
|
ak.close()
|
|
|
|
file_path = self._sanitize_file_path(cwd=cwd, file_path=path)
|
|
return file_path
|
|
|
|
def delete(self, path):
|
|
extra = {'_path': path}
|
|
self.logger.debug('Deleting file', extra=extra)
|
|
|
|
sftp = self.client.open_sftp()
|
|
sftp.unlink(path)
|
|
sftp.close()
|
|
return True
|
|
|
|
def run(self, cmd, timeout=None):
|
|
# type: (str, Optional[float]) -> Tuple[str, str, int]
|
|
"""
|
|
Note: This function is based on paramiko's exec_command()
|
|
method.
|
|
|
|
:param timeout: How long to wait (in seconds) for the command to
|
|
finish (optional).
|
|
:type timeout: ``float``
|
|
"""
|
|
extra1 = {'_cmd': cmd}
|
|
self.logger.debug('Executing command', extra=extra1)
|
|
|
|
# Use the system default buffer size
|
|
bufsize = -1
|
|
|
|
transport = self._get_transport()
|
|
|
|
chan = transport.open_session()
|
|
|
|
start_time = time.time()
|
|
chan.exec_command(cmd)
|
|
|
|
stdout = StringIO()
|
|
stderr = StringIO()
|
|
|
|
# Create a stdin file and immediately close it to prevent any
|
|
# interactive script from hanging the process.
|
|
stdin = chan.makefile('wb', bufsize)
|
|
stdin.close()
|
|
|
|
# Receive all the output
|
|
# Note #1: This is used instead of chan.makefile approach to prevent
|
|
# buffering issues and hanging if the executed command produces a lot
|
|
# of output.
|
|
#
|
|
# Note #2: If you are going to remove "ready" checks inside the loop
|
|
# you are going to have a bad time. Trying to consume from a channel
|
|
# which is not ready will block for indefinitely.
|
|
exit_status_ready = chan.exit_status_ready()
|
|
|
|
if exit_status_ready:
|
|
# It's possible that some data is already available when exit
|
|
# status is ready
|
|
stdout.write(self._consume_stdout(chan).getvalue())
|
|
stderr.write(self._consume_stderr(chan).getvalue())
|
|
|
|
while not exit_status_ready:
|
|
current_time = time.time()
|
|
elapsed_time = (current_time - start_time)
|
|
|
|
if timeout and (elapsed_time > timeout):
|
|
# TODO: Is this the right way to clean up?
|
|
chan.close()
|
|
|
|
stdout_str = stdout.getvalue() # type: str
|
|
stderr_str = stderr.getvalue() # type: str
|
|
raise SSHCommandTimeoutError(cmd=cmd, timeout=timeout,
|
|
stdout=stdout_str,
|
|
stderr=stderr_str)
|
|
|
|
stdout.write(self._consume_stdout(chan).getvalue())
|
|
stderr.write(self._consume_stderr(chan).getvalue())
|
|
|
|
# We need to check the exist status here, because the command could
|
|
# print some output and exit during this sleep below.
|
|
exit_status_ready = chan.exit_status_ready()
|
|
|
|
if exit_status_ready:
|
|
break
|
|
|
|
# Short sleep to prevent busy waiting
|
|
time.sleep(self.SLEEP_DELAY)
|
|
|
|
# Receive the exit status code of the command we ran.
|
|
status = chan.recv_exit_status() # type: int
|
|
|
|
stdout_str = stdout.getvalue()
|
|
stderr_str = stderr.getvalue()
|
|
|
|
extra2 = {'_status': status, '_stdout': stdout_str,
|
|
'_stderr': stderr_str}
|
|
self.logger.debug('Command finished', extra=extra2)
|
|
|
|
result = (stdout_str, stderr_str, status) # type: Tuple[str, str, int]
|
|
return result
|
|
|
|
def close(self):
|
|
self.logger.debug('Closing server connection')
|
|
|
|
if self.client:
|
|
self.client.close()
|
|
|
|
if self.sftp_client:
|
|
self.sftp_client.close()
|
|
|
|
return True
|
|
|
|
def _consume_stdout(self, chan):
|
|
"""
|
|
Try to consume stdout data from chan if it's receive ready.
|
|
"""
|
|
stdout = self._consume_data_from_channel(
|
|
chan=chan,
|
|
recv_method=chan.recv,
|
|
recv_ready_method=chan.recv_ready)
|
|
return stdout
|
|
|
|
def _consume_stderr(self, chan):
|
|
"""
|
|
Try to consume stderr data from chan if it's receive ready.
|
|
"""
|
|
stderr = self._consume_data_from_channel(
|
|
chan=chan,
|
|
recv_method=chan.recv_stderr,
|
|
recv_ready_method=chan.recv_stderr_ready)
|
|
return stderr
|
|
|
|
def _consume_data_from_channel(self, chan, recv_method, recv_ready_method):
|
|
"""
|
|
Try to consume data from the provided channel.
|
|
|
|
Keep in mind that data is only consumed if the channel is receive
|
|
ready.
|
|
"""
|
|
result = StringIO()
|
|
result_bytes = bytearray()
|
|
|
|
if recv_ready_method():
|
|
data = recv_method(self.CHUNK_SIZE)
|
|
result_bytes += b(data)
|
|
|
|
while data:
|
|
ready = recv_ready_method()
|
|
|
|
if not ready:
|
|
break
|
|
|
|
data = recv_method(self.CHUNK_SIZE)
|
|
result_bytes += b(data)
|
|
|
|
# We only decode data at the end because a single chunk could contain
|
|
# a part of multi byte UTF-8 character (whole multi bytes character
|
|
# could be split over two chunks)
|
|
result.write(result_bytes.decode('utf-8', errors='ignore'))
|
|
return result
|
|
|
|
def _get_pkey_object(self, key, password=None):
|
|
"""
|
|
Try to detect private key type and return paramiko.PKey object.
|
|
|
|
# NOTE: Paramiko only supports key in PKCS#1 PEM format.
|
|
"""
|
|
key_types = [
|
|
(paramiko.RSAKey, 'RSA'),
|
|
(paramiko.DSSKey, 'DSA'),
|
|
(paramiko.ECDSAKey, 'EC')
|
|
]
|
|
|
|
paramiko_version = getattr(paramiko, '__version__', '0.0.0')
|
|
paramiko_version = tuple([int(c) for c in paramiko_version.split('.')])
|
|
|
|
if paramiko_version >= (2, 2, 0):
|
|
# Ed25519 is only supported in paramiko >= 2.2.0
|
|
key_types.append((paramiko.ed25519key.Ed25519Key, 'Ed25519'))
|
|
|
|
for cls, key_type in key_types:
|
|
# Work around for paramiko not recognizing keys which start with
|
|
# "----BEGIN PRIVATE KEY-----"
|
|
# Since key is already in PEM format, we just try changing the
|
|
# header and footer
|
|
key_split = key.strip().splitlines()
|
|
if (key_split[0] == '-----BEGIN PRIVATE KEY-----' and
|
|
key_split[-1] == '-----END PRIVATE KEY-----'):
|
|
key_split[0] = '-----BEGIN %s PRIVATE KEY-----' % (key_type)
|
|
key_split[-1] = '-----END %s PRIVATE KEY-----' % (key_type)
|
|
|
|
key_value = '\n'.join(key_split)
|
|
else:
|
|
# Already a valid key, us it as is
|
|
key_value = key
|
|
|
|
try:
|
|
key = cls.from_private_key(StringIO(key_value), password)
|
|
except paramiko.ssh_exception.PasswordRequiredException as e:
|
|
raise e
|
|
except (paramiko.ssh_exception.SSHException, AssertionError) as e:
|
|
if 'private key file checkints do not match' in str(e).lower():
|
|
msg = ('Invalid password provided for encrypted key. '
|
|
'Original error: %s' % (str(e)))
|
|
# Indicates invalid password for password protected keys
|
|
raise paramiko.ssh_exception.SSHException(msg)
|
|
|
|
# Invalid key, try other key type
|
|
pass
|
|
else:
|
|
return key
|
|
|
|
msg = ('Invalid or unsupported key type (only RSA, DSS, ECDSA and'
|
|
' Ed25519 keys'
|
|
' in PEM format are supported). For more information on '
|
|
' supported key file types, see %s' % (SUPPORTED_KEY_TYPES_URL))
|
|
raise paramiko.ssh_exception.SSHException(msg)
|
|
|
|
def _sanitize_cwd(self, cwd):
|
|
# type: (str) -> str
|
|
# getcwd() returns an invalid path when executing commands on Windows
|
|
# so we need a special case for that scenario
|
|
# For example, we convert /C:/Users/Foo -> C:/Users/Foo
|
|
if re.match(r"^\/\w\:.*$", str(cwd)):
|
|
cwd = str(cwd[1:])
|
|
|
|
return cwd
|
|
|
|
def _sanitize_file_path(self, cwd, file_path):
|
|
# type: (str, str) -> str
|
|
"""
|
|
Sanitize the provided file path and ensure we always return an
|
|
absolute path, even if relative path is passed to to this function.
|
|
"""
|
|
|
|
if file_path[0] in ['/', '\\'] or re.match(r"^\w\:.*$", file_path):
|
|
# If it's an absolute path we return path as is
|
|
# NOTE: We assume it's a Windows absolute path if it's starts with
|
|
# a drive letter - e.g. C:\\..., D:\\, etc. or with \
|
|
pass
|
|
else:
|
|
if re.match(r"^\w\:.*$", cwd):
|
|
# Windows path
|
|
file_path = cwd + '\\' + file_path
|
|
else:
|
|
file_path = pjoin(cwd, file_path)
|
|
|
|
return file_path
|
|
|
|
def _get_transport(self):
|
|
"""
|
|
Return transport object taking into account keep alive and compression
|
|
options passed to the constructor.
|
|
"""
|
|
transport = self.client.get_transport()
|
|
|
|
if self.keep_alive:
|
|
transport.set_keepalive(self.keep_alive)
|
|
|
|
if self.use_compression:
|
|
transport.use_compression(compress=True)
|
|
|
|
return transport
|
|
|
|
def _get_sftp_client(self):
|
|
"""
|
|
Create SFTP client from the underlying SSH client.
|
|
|
|
This method tries to re-use the existing self.sftp_client (if it
|
|
exists) and it also tries to verify the connection is opened and if
|
|
it's not, it will try to re-establish it.
|
|
"""
|
|
if not self.sftp_client:
|
|
self.sftp_client = self.client.open_sftp()
|
|
|
|
sftp_client = self.sftp_client
|
|
|
|
# Verify the connection is still open, if it's not, try to
|
|
# re-establish it.
|
|
# We do that, by calling listdir(). If it returns "Socket is closed"
|
|
# error we assume the connection is closed and we try to re-establish
|
|
# it.
|
|
try:
|
|
sftp_client.listdir(".")
|
|
except OSError as e:
|
|
if "socket is closed" in str(e).lower():
|
|
self.sftp_client = self.client.open_sftp()
|
|
elif "no such file" in str(e).lower():
|
|
# Not a fatal exception, means connection is still open
|
|
pass
|
|
else:
|
|
raise e
|
|
|
|
return self.sftp_client
|
|
|
|
|
|
class ShellOutSSHClient(BaseSSHClient):
|
|
"""
|
|
This client shells out to "ssh" binary to run commands on the remote
|
|
server.
|
|
|
|
Note: This client should not be used in production.
|
|
"""
|
|
|
|
def __init__(self,
|
|
hostname, # type: str
|
|
port=22, # type: int
|
|
username='root', # type: str
|
|
password=None, # type: Optional[str]
|
|
key=None, # type: Optional[str]
|
|
key_files=None, # type: Optional[str]
|
|
timeout=None # type: Optional[float]
|
|
):
|
|
super(ShellOutSSHClient, self).__init__(hostname=hostname,
|
|
port=port, username=username,
|
|
password=password,
|
|
key=key,
|
|
key_files=key_files,
|
|
timeout=timeout)
|
|
if self.password:
|
|
raise ValueError('ShellOutSSHClient only supports key auth')
|
|
|
|
child = subprocess.Popen(['ssh'], stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE)
|
|
child.communicate()
|
|
|
|
if child.returncode == 127:
|
|
raise ValueError('ssh client is not available')
|
|
|
|
self.logger = self._get_and_setup_logger()
|
|
|
|
def connect(self):
|
|
"""
|
|
This client doesn't support persistent connections establish a new
|
|
connection every time "run" method is called.
|
|
"""
|
|
return True
|
|
|
|
def run(self, cmd, timeout=None):
|
|
return self._run_remote_shell_command([cmd])
|
|
|
|
def put(self, path, contents=None, chmod=None, mode='w'):
|
|
if mode == 'w':
|
|
redirect = '>'
|
|
elif mode == 'a':
|
|
redirect = '>>'
|
|
else:
|
|
raise ValueError('Invalid mode: ' + mode)
|
|
|
|
cmd = ['echo "%s" %s %s' % (contents, redirect, path)]
|
|
self._run_remote_shell_command(cmd)
|
|
return path
|
|
|
|
def putfo(self, path, fo=None, chmod=None):
|
|
content = fo.read()
|
|
return self.put(path=path, contents=content, chmod=chmod)
|
|
|
|
def delete(self, path):
|
|
cmd = ['rm', '-rf', path]
|
|
self._run_remote_shell_command(cmd)
|
|
return True
|
|
|
|
def close(self):
|
|
return True
|
|
|
|
def _get_base_ssh_command(self):
|
|
# type: () -> List[str]
|
|
cmd = ['ssh']
|
|
|
|
if self.key_files:
|
|
self.key_files = cast(str, self.key_files)
|
|
cmd += ['-i', self.key_files]
|
|
|
|
if self.timeout:
|
|
cmd += ['-oConnectTimeout=%s' % (self.timeout)]
|
|
|
|
cmd += ['%s@%s' % (self.username, self.hostname)]
|
|
|
|
return cmd
|
|
|
|
def _run_remote_shell_command(self, cmd):
|
|
# type: (List[str]) -> Tuple[str, str, int]
|
|
"""
|
|
Run a command on a remote server.
|
|
|
|
:param cmd: Command to run.
|
|
:type cmd: ``list`` of ``str``
|
|
|
|
:return: Command stdout, stderr and status code.
|
|
:rtype: ``tuple``
|
|
"""
|
|
base_cmd = self._get_base_ssh_command()
|
|
full_cmd = base_cmd + [' '.join(cmd)]
|
|
|
|
self.logger.debug('Executing command: "%s"' % (' '.join(full_cmd)))
|
|
|
|
child = subprocess.Popen(full_cmd, stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE)
|
|
stdout, stderr = child.communicate()
|
|
|
|
stdout_str = cast(str, stdout)
|
|
stderr_str = cast(str, stdout)
|
|
|
|
return (stdout_str, stderr_str, child.returncode)
|
|
|
|
|
|
class MockSSHClient(BaseSSHClient):
|
|
pass
|
|
|
|
|
|
SSHClient = ParamikoSSHClient # type: Type[BaseSSHClient]
|
|
if not have_paramiko:
|
|
SSHClient = MockSSHClient # type: ignore
|