Initial commit

add first helper: reclass-dump-params
diff --git a/reclass_tools/helpers/ssh_client.py b/reclass_tools/helpers/ssh_client.py
new file mode 100644
index 0000000..da3655d
--- /dev/null
+++ b/reclass_tools/helpers/ssh_client.py
@@ -0,0 +1,1147 @@
+#    Copyright 2013 - 2016 Mirantis, Inc.
+#
+#    Licensed 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.
+
+from __future__ import unicode_literals
+
+import base64
+import os
+import posixpath
+import stat
+import sys
+import threading
+import time
+import warnings
+
+import paramiko
+import six
+
+from reclass_tools.helpers import decorators
+from reclass_tools.helpers import exec_result
+from reclass_tools.helpers import proc_enums
+from reclass_tools import logger
+
+
+def get_private_keys(home, identity_files=None):
+    if not identity_files:
+        identity_files = ['.ssh/id_rsa']
+    keys = []
+    for i in identity_files:
+        with open(os.path.join(home, i)) as f:
+            keys.append(paramiko.RSAKey.from_private_key(f))
+    return keys
+
+
+class SSHAuth(object):
+    __slots__ = ['__username', '__password', '__key', '__keys']
+
+    def __init__(
+            self,
+            username=None, password=None, key=None, keys=None):
+        """SSH authorisation object
+
+        Used to authorize SSHClient.
+        Single SSHAuth object is associated with single host:port.
+        Password and key is private, other data is read-only.
+
+        :type username: str
+        :type password: str
+        :type key: paramiko.RSAKey
+        :type keys: list
+        """
+        self.__username = username
+        self.__password = password
+        self.__key = key
+        self.__keys = [None]
+        if key is not None:
+            # noinspection PyTypeChecker
+            self.__keys.append(key)
+        if keys is not None:
+            for key in keys:
+                if key not in self.__keys:
+                    self.__keys.append(key)
+
+    @property
+    def username(self):
+        """Username for auth
+
+        :rtype: str
+        """
+        return self.__username
+
+    @staticmethod
+    def __get_public_key(key):
+        """Internal method for get public key from private
+
+        :type key: paramiko.RSAKey
+        """
+        if key is None:
+            return None
+        return '{0} {1}'.format(key.get_name(), key.get_base64())
+
+    @property
+    def public_key(self):
+        """public key for stored private key if presents else None
+
+        :rtype: str
+        """
+        return self.__get_public_key(self.__key)
+
+    def enter_password(self, tgt):
+        """Enter password to STDIN
+
+        Note: required for 'sudo' call
+
+        :type tgt: file
+        :rtype: str
+        """
+        # noinspection PyTypeChecker
+        return tgt.write('{}\n'.format(self.__password))
+
+    def connect(self, client, hostname=None, port=22, log=True):
+        """Connect SSH client object using credentials
+
+        :type client:
+            paramiko.client.SSHClient
+            paramiko.transport.Transport
+        :type log: bool
+        :raises paramiko.AuthenticationException
+        """
+        kwargs = {
+            'username': self.username,
+            'password': self.__password}
+        if hostname is not None:
+            kwargs['hostname'] = hostname
+            kwargs['port'] = port
+
+        keys = [self.__key]
+        keys.extend([k for k in self.__keys if k != self.__key])
+
+        for key in keys:
+            kwargs['pkey'] = key
+            try:
+                client.connect(**kwargs)
+                if self.__key != key:
+                    self.__key = key
+                    logger.debug(
+                        'Main key has been updated, public key is: \n'
+                        '{}'.format(self.public_key))
+                return
+            except paramiko.PasswordRequiredException:
+                if self.__password is None:
+                    logger.exception('No password has been set!')
+                    raise
+                else:
+                    logger.critical(
+                        'Unexpected PasswordRequiredException, '
+                        'when password is set!')
+                    raise
+            except paramiko.AuthenticationException:
+                continue
+        msg = 'Connection using stored authentication info failed!'
+        if log:
+            logger.exception(
+                'Connection using stored authentication info failed!')
+        raise paramiko.AuthenticationException(msg)
+
+    def __hash__(self):
+        return hash((
+            self.__class__,
+            self.username,
+            self.__password,
+            tuple(self.__keys)
+        ))
+
+    def __eq__(self, other):
+        return hash(self) == hash(other)
+
+    def __ne__(self, other):
+        return not self.__eq__(other)
+
+    def __deepcopy__(self, memo):
+        return self.__class__(
+            username=self.username,
+            password=self.__password,
+            key=self.__key,
+            keys=self.__keys.copy()
+        )
+
+    def copy(self):
+        return self.__class__(
+            username=self.username,
+            password=self.__password,
+            key=self.__key,
+            keys=self.__keys
+        )
+
+    def __repr__(self):
+        _key = (
+            None if self.__key is None else
+            '<private for pub: {}>'.format(self.public_key)
+        )
+        _keys = []
+        for k in self.__keys:
+            if k == self.__key:
+                continue
+            # noinspection PyTypeChecker
+            _keys.append(
+                '<private for pub: {}>'.format(
+                    self.__get_public_key(key=k)) if k is not None else None)
+
+        return (
+            '{cls}(username={username}, '
+            'password=<*masked*>, key={key}, keys={keys})'.format(
+                cls=self.__class__.__name__,
+                username=self.username,
+                key=_key,
+                keys=_keys)
+        )
+
+    def __str__(self):
+        return (
+            '{cls} for {username}'.format(
+                cls=self.__class__.__name__,
+                username=self.username,
+            )
+        )
+
+
+class _MemorizedSSH(type):
+    """Memorize metaclass for SSHClient
+
+    This class implements caching and managing of SSHClient connections.
+    Class is not in public scope: all required interfaces is accessible throw
+      SSHClient classmethods.
+
+    Main flow is:
+      SSHClient() -> check for cached connection and
+        - If exists the same: check for alive, reconnect if required and return
+        - If exists with different credentials: delete and continue processing
+          create new connection and cache on success
+      * Note: each invocation of SSHClient instance will return current dir to
+        the root of the current user home dir ("cd ~").
+        It is necessary to avoid unpredictable behavior when the same
+        connection is used from different places.
+        If you need to enter some directory and execute command there, please
+        use the following approach:
+        cmd1 = "cd <some dir> && <command1>"
+        cmd2 = "cd <some dir> && <command2>"
+
+    Close cached connections is allowed per-client and all stored:
+      connection will be closed, but still stored in cache for faster reconnect
+
+    Clear cache is strictly not recommended:
+      from this moment all open connections should be managed manually,
+      duplicates is possible.
+    """
+    __cache = {}
+
+    def __call__(
+            cls,
+            host, port=22,
+            username=None, password=None, private_keys=None,
+            auth=None
+    ):
+        """Main memorize method: check for cached instance and return it
+
+        :type host: str
+        :type port: int
+        :type username: str
+        :type password: str
+        :type private_keys: list
+        :type auth: SSHAuth
+        :rtype: SSHClient
+        """
+        if (host, port) in cls.__cache:
+            key = host, port
+            if auth is None:
+                auth = SSHAuth(
+                    username=username, password=password, keys=private_keys)
+            if hash((cls, host, port, auth)) == hash(cls.__cache[key]):
+                ssh = cls.__cache[key]
+                # noinspection PyBroadException
+                try:
+                    ssh.execute('cd ~', timeout=5)
+                except BaseException:  # Note: Do not change to lower level!
+                    logger.debug('Reconnect {}'.format(ssh))
+                    ssh.reconnect()
+                return ssh
+            if sys.getrefcount(cls.__cache[key]) == 2:
+                # If we have only cache reference and temporary getrefcount
+                # reference: close connection before deletion
+                logger.debug('Closing {} as unused'.format(cls.__cache[key]))
+                cls.__cache[key].close()
+            del cls.__cache[key]
+        # noinspection PyArgumentList
+        return super(
+            _MemorizedSSH, cls).__call__(
+            host=host, port=port,
+            username=username, password=password, private_keys=private_keys,
+            auth=auth)
+
+    @classmethod
+    def record(mcs, ssh):
+        """Record SSH client to cache
+
+        :type ssh: SSHClient
+        """
+        mcs.__cache[(ssh.hostname, ssh.port)] = ssh
+
+    @classmethod
+    def clear_cache(mcs):
+        """Clear cached connections for initialize new instance on next call"""
+        n_count = 3 if six.PY3 else 4
+        # PY3: cache, ssh, temporary
+        # PY4: cache, values mapping, ssh, temporary
+        for ssh in mcs.__cache.values():
+            if sys.getrefcount(ssh) == n_count:
+                logger.debug('Closing {} as unused'.format(ssh))
+                ssh.close()
+        mcs.__cache = {}
+
+    @classmethod
+    def close_connections(mcs, hostname=None):
+        """Close connections for selected or all cached records
+
+        :type hostname: str
+        """
+        if hostname is None:
+            keys = [key for key, ssh in mcs.__cache.items() if ssh.is_alive]
+        else:
+            keys = [
+                (host, port)
+                for (host, port), ssh
+                in mcs.__cache.items() if host == hostname and ssh.is_alive]
+        # raise ValueError(keys)
+        for key in keys:
+            mcs.__cache[key].close()
+
+
+class SSHClient(six.with_metaclass(_MemorizedSSH, object)):
+    __slots__ = [
+        '__hostname', '__port', '__auth', '__ssh', '__sftp', 'sudo_mode',
+        '__lock'
+    ]
+
+    class __get_sudo(object):
+        """Context manager for call commands with sudo"""
+        def __init__(self, ssh, enforce=None):
+            """Context manager for call commands with sudo
+
+            :type ssh: SSHClient
+            :type enforce: bool
+            """
+            self.__ssh = ssh
+            self.__sudo_status = ssh.sudo_mode
+            self.__enforce = enforce
+
+        def __enter__(self):
+            self.__sudo_status = self.__ssh.sudo_mode
+            if self.__enforce is not None:
+                self.__ssh.sudo_mode = self.__enforce
+
+        def __exit__(self, exc_type, exc_val, exc_tb):
+            self.__ssh.sudo_mode = self.__sudo_status
+
+    # noinspection PyPep8Naming
+    class get_sudo(__get_sudo):
+        """Context manager for call commands with sudo"""
+
+        def __init__(self, ssh, enforce=True):
+            warnings.warn(
+                'SSHClient.get_sudo(SSHClient()) is deprecated in favor of '
+                'SSHClient().sudo(enforce=...) , which is much more powerful.')
+            super(self.__class__, self).__init__(ssh=ssh, enforce=enforce)
+
+    def __hash__(self):
+        return hash((
+            self.__class__,
+            self.hostname,
+            self.port,
+            self.auth))
+
+    def __init__(
+            self,
+            host, port=22,
+            username=None, password=None, private_keys=None,
+            auth=None
+    ):
+        """SSHClient helper
+
+        :type host: str
+        :type port: int
+        :type username: str
+        :type password: str
+        :type private_keys: list
+        :type auth: SSHAuth
+        """
+        self.__lock = threading.RLock()
+
+        self.__hostname = host
+        self.__port = port
+
+        self.sudo_mode = False
+        self.__ssh = paramiko.SSHClient()
+        self.__ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
+        self.__sftp = None
+
+        self.__auth = auth if auth is None else auth.copy()
+
+        if auth is None:
+            msg = (
+                'SSHClient(host={host}, port={port}, username={username}): '
+                'initialization by username/password/private_keys '
+                'is deprecated in favor of SSHAuth usage. '
+                'Please update your code'.format(
+                    host=host, port=port, username=username
+                ))
+            warnings.warn(msg, DeprecationWarning)
+            logger.debug(msg)
+
+            self.__auth = SSHAuth(
+                username=username,
+                password=password,
+                keys=private_keys
+            )
+
+        self.__connect()
+        _MemorizedSSH.record(ssh=self)
+        if auth is None:
+            logger.info(
+                '{0}:{1}> SSHAuth was made from old style creds: '
+                '{2}'.format(self.hostname, self.port, self.auth))
+
+    @property
+    def lock(self):
+        """Connection lock
+
+        :rtype: threading.RLock
+        """
+        return self.__lock
+
+    @property
+    def auth(self):
+        """Internal authorisation object
+
+        Attention: this public property is mainly for inheritance,
+        debug and information purposes.
+        Calls outside SSHClient and child classes is sign of incorrect design.
+        Change is completely disallowed.
+
+        :rtype: SSHAuth
+        """
+        return self.__auth
+
+    @property
+    def hostname(self):
+        """Connected remote host name
+
+        :rtype: str
+        """
+        return self.__hostname
+
+    @property
+    def host(self):
+        """Hostname access for backward compatibility
+
+        :rtype: str
+        """
+        warnings.warn(
+            'host has been deprecated in favor of hostname',
+            DeprecationWarning
+        )
+        return self.hostname
+
+    @property
+    def port(self):
+        """Connected remote port number
+
+        :rtype: int
+        """
+        return self.__port
+
+    @property
+    def is_alive(self):
+        """Paramiko status: ready to use|reconnect required
+
+        :rtype: bool
+        """
+        return self.__ssh.get_transport() is not None
+
+    def __repr__(self):
+        return '{cls}(host={host}, port={port}, auth={auth!r})'.format(
+            cls=self.__class__.__name__, host=self.hostname, port=self.port,
+            auth=self.auth
+        )
+
+    def __str__(self):
+        return '{cls}(host={host}, port={port}) for user {user}'.format(
+            cls=self.__class__.__name__, host=self.hostname, port=self.port,
+            user=self.auth.username
+        )
+
+    @property
+    def _ssh(self):
+        """ssh client object getter for inheritance support only
+
+        Attention: ssh client object creation and change
+        is allowed only by __init__ and reconnect call.
+
+        :rtype: paramiko.SSHClient
+        """
+        return self.__ssh
+
+    @decorators.retry(paramiko.SSHException, count=3, delay=3)
+    def __connect(self):
+        """Main method for connection open"""
+        with self.lock:
+            self.auth.connect(
+                client=self.__ssh,
+                hostname=self.hostname, port=self.port,
+                log=True)
+
+    def __connect_sftp(self):
+        """SFTP connection opener"""
+        with self.lock:
+            try:
+                self.__sftp = self.__ssh.open_sftp()
+            except paramiko.SSHException:
+                logger.warning('SFTP enable failed! SSH only is accessible.')
+
+    @property
+    def _sftp(self):
+        """SFTP channel access for inheritance
+
+        :rtype: paramiko.sftp_client.SFTPClient
+        :raises: paramiko.SSHException
+        """
+        if self.__sftp is not None:
+            return self.__sftp
+        logger.debug('SFTP is not connected, try to connect...')
+        self.__connect_sftp()
+        if self.__sftp is not None:
+            return self.__sftp
+        raise paramiko.SSHException('SFTP connection failed')
+
+    def close(self):
+        """Close SSH and SFTP sessions"""
+        with self.lock:
+            # noinspection PyBroadException
+            try:
+                self.__ssh.close()
+                self.__sftp = None
+            except Exception:
+                logger.exception("Could not close ssh connection")
+                if self.__sftp is not None:
+                    # noinspection PyBroadException
+                    try:
+                        self.__sftp.close()
+                    except Exception:
+                        logger.exception("Could not close sftp connection")
+
+    @staticmethod
+    def clear():
+        warnings.warn(
+            "clear is removed: use close() only if it mandatory: "
+            "it's automatically called on revert|shutdown|suspend|destroy",
+            DeprecationWarning
+        )
+
+    @classmethod
+    def _clear_cache(cls):
+        """Enforce clear memorized records"""
+        warnings.warn(
+            '_clear_cache() is dangerous and not recommended for normal use!',
+            Warning
+        )
+        _MemorizedSSH.clear_cache()
+
+    @classmethod
+    def close_connections(cls, hostname=None):
+        """Close cached connections: if hostname is not set, then close all
+
+        :type hostname: str
+        """
+        _MemorizedSSH.close_connections(hostname=hostname)
+
+    def __del__(self):
+        """Destructor helper: close channel and threads BEFORE closing others
+
+        Due to threading in paramiko, default destructor could generate asserts
+        on close, so we calling channel close before closing main ssh object.
+        """
+        self.__ssh.close()
+        self.__sftp = None
+
+    def __enter__(self):
+        return self
+
+    def __exit__(self, exc_type, exc_val, exc_tb):
+        pass
+
+    def reconnect(self):
+        """Reconnect SSH session"""
+        with self.lock:
+            self.close()
+
+            self.__ssh = paramiko.SSHClient()
+            self.__ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
+
+            self.__connect()
+
+    def sudo(self, enforce=None):
+        """Call contextmanager for sudo mode change
+
+        :type enforce: bool
+        :param enforce: Enforce sudo enabled or disabled. By default: None
+        """
+        return self.__get_sudo(ssh=self, enforce=enforce)
+
+    def check_call(
+            self,
+            command, verbose=False, timeout=None,
+            error_info=None,
+            expected=None, raise_on_err=True, **kwargs):
+        """Execute command and check for return code
+
+        :type command: str
+        :type verbose: bool
+        :type timeout: int
+        :type error_info: str
+        :type expected: list
+        :type raise_on_err: bool
+        :rtype: ExecResult
+        :raises: DevopsCalledProcessError
+        """
+        if expected is None:
+            expected = [proc_enums.ExitCodes.EX_OK]
+        else:
+            expected = [
+                proc_enums.ExitCodes(code)
+                if (
+                    isinstance(code, int) and
+                    code in proc_enums.ExitCodes.__members__.values())
+                else code
+                for code in expected
+                ]
+        ret = self.execute(command, verbose, timeout, **kwargs)
+        if ret['exit_code'] not in expected:
+            message = (
+                "{append}Command '{cmd!r}' returned exit code {code!s} while "
+                "expected {expected!s}\n".format(
+                    append=error_info + '\n' if error_info else '',
+                    cmd=command,
+                    code=ret['exit_code'],
+                    expected=expected,
+                ))
+            logger.error(message)
+            if raise_on_err:
+                raise SSHCalledProcessError(
+                    command, ret['exit_code'],
+                    expected=expected,
+                    stdout=ret['stdout_brief'],
+                    stderr=ret['stdout_brief'])
+        return ret
+
+    def check_stderr(
+            self,
+            command, verbose=False, timeout=None,
+            error_info=None,
+            raise_on_err=True, **kwargs):
+        """Execute command expecting return code 0 and empty STDERR
+
+        :type command: str
+        :type verbose: bool
+        :type timeout: int
+        :type error_info: str
+        :type raise_on_err: bool
+        :rtype: ExecResult
+        :raises: DevopsCalledProcessError
+        """
+        ret = self.check_call(
+            command, verbose, timeout=timeout,
+            error_info=error_info, raise_on_err=raise_on_err, **kwargs)
+        if ret['stderr']:
+            message = (
+                "{append}Command '{cmd!r}' STDERR while not expected\n"
+                "\texit code: {code!s}\n".format(
+                    append=error_info + '\n' if error_info else '',
+                    cmd=command,
+                    code=ret['exit_code'],
+                ))
+            logger.error(message)
+            if raise_on_err:
+                raise SSHCalledProcessError(
+                    command,
+                    ret['exit_code'],
+                    stdout=ret['stdout_brief'],
+                    stderr=ret['stdout_brief'])
+        return ret
+
+    @classmethod
+    def execute_together(
+            cls, remotes, command, expected=None, raise_on_err=True, **kwargs):
+        """Execute command on multiple remotes in async mode
+
+        :type remotes: list
+        :type command: str
+        :type expected: list
+        :type raise_on_err: bool
+        :raises: DevopsCalledProcessError
+        """
+        if expected is None:
+            expected = [0]
+        futures = {}
+        errors = {}
+        for remote in set(remotes):  # Use distinct remotes
+            chan, _, _, _ = remote.execute_async(command, **kwargs)
+            futures[remote] = chan
+        for remote, chan in futures.items():
+            ret = chan.recv_exit_status()
+            chan.close()
+            if ret not in expected:
+                errors[remote.hostname] = ret
+        if errors and raise_on_err:
+            raise SSHCalledProcessError(command, errors)
+
+    @classmethod
+    def __exec_command(
+            cls, command, channel, stdout, stderr, timeout, verbose=False):
+        """Get exit status from channel with timeout
+
+        :type command: str
+        :type channel: paramiko.channel.Channel
+        :type stdout: paramiko.channel.ChannelFile
+        :type stderr: paramiko.channel.ChannelFile
+        :type timeout: int
+        :type verbose: bool
+        :rtype: ExecResult
+        :raises: TimeoutError
+        """
+        def poll_stream(src, verb_logger=None):
+            dst = []
+            try:
+                for line in src:
+                    dst.append(line)
+                    if verb_logger is not None:
+                        verb_logger(
+                            line.decode('utf-8',
+                                        errors='backslashreplace').rstrip()
+                        )
+            except IOError:
+                pass
+            return dst
+
+        def poll_streams(result, channel, stdout, stderr, verbose):
+            if channel.recv_ready():
+                result.stdout += poll_stream(
+                    src=stdout,
+                    verb_logger=logger.info if verbose else logger.debug)
+            if channel.recv_stderr_ready():
+                result.stderr += poll_stream(
+                    src=stderr,
+                    verb_logger=logger.error if verbose else logger.debug)
+
+        @decorators.threaded(started=True)
+        def poll_pipes(stdout, stderr, result, stop, channel):
+            """Polling task for FIFO buffers
+
+            :type stdout: paramiko.channel.ChannelFile
+            :type stderr: paramiko.channel.ChannelFile
+            :type result: ExecResult
+            :type stop: Event
+            :type channel: paramiko.channel.Channel
+            """
+
+            while not stop.isSet():
+                time.sleep(0.1)
+                poll_streams(
+                    result=result,
+                    channel=channel,
+                    stdout=stdout,
+                    stderr=stderr,
+                    verbose=verbose
+                )
+
+                if channel.status_event.is_set():
+                    result.exit_code = result.exit_code = channel.exit_status
+
+                    result.stdout += poll_stream(
+                        src=stdout,
+                        verb_logger=logger.info if verbose else logger.debug)
+                    result.stderr += poll_stream(
+                        src=stderr,
+                        verb_logger=logger.error if verbose else logger.debug)
+
+                    stop.set()
+
+        # channel.status_event.wait(timeout)
+        result = exec_result.ExecResult(cmd=command)
+        stop_event = threading.Event()
+        if verbose:
+            logger.info("\nExecuting command: {!r}".format(command.rstrip()))
+        else:
+            logger.debug("\nExecuting command: {!r}".format(command.rstrip()))
+        poll_pipes(
+            stdout=stdout,
+            stderr=stderr,
+            result=result,
+            stop=stop_event,
+            channel=channel
+        )
+
+        stop_event.wait(timeout)
+
+        # Process closed?
+        if stop_event.isSet():
+            stop_event.clear()
+            channel.close()
+            return result
+
+        stop_event.set()
+        channel.close()
+
+        wait_err_msg = ('Wait for {0!r} during {1}s: no return code!\n'
+                        .format(command, timeout))
+        output_brief_msg = ('\tSTDOUT:\n'
+                            '{0}\n'
+                            '\tSTDERR"\n'
+                            '{1}'.format(result.stdout_brief,
+                                         result.stderr_brief))
+        logger.debug(wait_err_msg)
+        raise SSHTimeoutError(wait_err_msg + output_brief_msg)
+
+    def execute(self, command, verbose=False, timeout=None, **kwargs):
+        """Execute command and wait for return code
+
+        :type command: str
+        :type verbose: bool
+        :type timeout: int
+        :rtype: ExecResult
+        :raises: TimeoutError
+        """
+        chan, _, stderr, stdout = self.execute_async(command, **kwargs)
+
+        result = self.__exec_command(
+            command, chan, stdout, stderr, timeout,
+            verbose=verbose
+        )
+
+        message = (
+            '\n{cmd!r} execution results: Exit code: {code!s}'.format(
+                cmd=command,
+                code=result.exit_code
+            ))
+        if verbose:
+            logger.info(message)
+        else:
+            logger.debug(message)
+        return result
+
+    def execute_async(self, command, get_pty=False):
+        """Execute command in async mode and return channel with IO objects
+
+        :type command: str
+        :type get_pty: bool
+        :rtype:
+            tuple(
+                paramiko.Channel,
+                paramiko.ChannelFile,
+                paramiko.ChannelFile,
+                paramiko.ChannelFile
+            )
+        """
+        logger.debug("Executing command: {!r}".format(command.rstrip()))
+
+        chan = self._ssh.get_transport().open_session()
+
+        if get_pty:
+            # Open PTY
+            chan.get_pty(
+                term='vt100',
+                width=80, height=24,
+                width_pixels=0, height_pixels=0
+            )
+
+        stdin = chan.makefile('wb')
+        stdout = chan.makefile('rb')
+        stderr = chan.makefile_stderr('rb')
+        cmd = "{}\n".format(command)
+        if self.sudo_mode:
+            encoded_cmd = base64.b64encode(cmd.encode('utf-8')).decode('utf-8')
+            cmd = ("sudo -S bash -c 'eval \"$(base64 -d "
+                   "<(echo \"{0}\"))\"'").format(
+                encoded_cmd
+            )
+            chan.exec_command(cmd)
+            if stdout.channel.closed is False:
+                self.auth.enter_password(stdin)
+                stdin.flush()
+        else:
+            chan.exec_command(cmd)
+        return chan, stdin, stderr, stdout
+
+    def execute_through_host(
+            self,
+            hostname,
+            cmd,
+            auth=None,
+            target_port=22,
+            timeout=None,
+            verbose=False
+    ):
+        """Execute command on remote host through currently connected host
+
+        :type hostname: str
+        :type cmd: str
+        :type auth: SSHAuth
+        :type target_port: int
+        :type timeout: int
+        :type verbose: bool
+        :rtype: ExecResult
+        :raises: TimeoutError
+        """
+        if auth is None:
+            auth = self.auth
+
+        intermediate_channel = self._ssh.get_transport().open_channel(
+            kind='direct-tcpip',
+            dest_addr=(hostname, target_port),
+            src_addr=(self.hostname, 0))
+        transport = paramiko.Transport(sock=intermediate_channel)
+
+        # start client and authenticate transport
+        auth.connect(transport)
+
+        # open ssh session
+        channel = transport.open_session()
+
+        # Make proxy objects for read
+        stdout = channel.makefile('rb')
+        stderr = channel.makefile_stderr('rb')
+
+        channel.exec_command(cmd)
+
+        # noinspection PyDictCreation
+        result = self.__exec_command(
+            cmd, channel, stdout, stderr, timeout, verbose=verbose)
+
+        intermediate_channel.close()
+
+        return result
+
+    def mkdir(self, path):
+        """run 'mkdir -p path' on remote
+
+        :type path: str
+        """
+        if self.exists(path):
+            return
+        logger.debug("Creating directory: {}".format(path))
+        # noinspection PyTypeChecker
+        self.execute("mkdir -p {}\n".format(path))
+
+    def rm_rf(self, path):
+        """run 'rm -rf path' on remote
+
+        :type path: str
+        """
+        logger.debug("rm -rf {}".format(path))
+        # noinspection PyTypeChecker
+        self.execute("rm -rf {}".format(path))
+
+    def open(self, path, mode='r'):
+        """Open file on remote using SFTP session
+
+        :type path: str
+        :type mode: str
+        :return: file.open() stream
+        """
+        return self._sftp.open(path, mode)
+
+    def upload(self, source, target):
+        """Upload file(s) from source to target using SFTP session
+
+        :type source: str
+        :type target: str
+        """
+        logger.debug("Copying '%s' -> '%s'", source, target)
+
+        if self.isdir(target):
+            target = posixpath.join(target, os.path.basename(source))
+
+        source = os.path.expanduser(source)
+        if not os.path.isdir(source):
+            self._sftp.put(source, target)
+            return
+
+        for rootdir, _, files in os.walk(source):
+            targetdir = os.path.normpath(
+                os.path.join(
+                    target,
+                    os.path.relpath(rootdir, source))).replace("\\", "/")
+
+            self.mkdir(targetdir)
+
+            for entry in files:
+                local_path = os.path.join(rootdir, entry)
+                remote_path = posixpath.join(targetdir, entry)
+                if self.exists(remote_path):
+                    self._sftp.unlink(remote_path)
+                self._sftp.put(local_path, remote_path)
+
+    def download(self, destination, target):
+        """Download file(s) to target from destination
+
+        :type destination: str
+        :type target: str
+        :rtype: bool
+        """
+        logger.debug(
+            "Copying '%s' -> '%s' from remote to local host",
+            destination, target
+        )
+
+        if os.path.isdir(target):
+            target = posixpath.join(target, os.path.basename(destination))
+
+        if not self.isdir(destination):
+            if self.exists(destination):
+                self._sftp.get(destination, target)
+            else:
+                logger.debug(
+                    "Can't download %s because it doesn't exist", destination
+                )
+        else:
+            logger.debug(
+                "Can't download %s because it is a directory", destination
+            )
+        return os.path.exists(target)
+
+    def exists(self, path):
+        """Check for file existence using SFTP session
+
+        :type path: str
+        :rtype: bool
+        """
+        try:
+            self._sftp.lstat(path)
+            return True
+        except IOError:
+            return False
+
+    def stat(self, path):
+        """Get stat info for path with following symlinks
+
+        :type path: str
+        :rtype: paramiko.sftp_attr.SFTPAttributes
+        """
+        return self._sftp.stat(path)
+
+    def isfile(self, path, follow_symlink=False):
+        """Check, that path is file using SFTP session
+
+        :type path: str
+        :type follow_symlink: bool (default=False), resolve symlinks
+        :rtype: bool
+        """
+        try:
+            if follow_symlink:
+                attrs = self._sftp.stat(path)
+            else:
+                attrs = self._sftp.lstat(path)
+            return attrs.st_mode & stat.S_IFREG != 0
+        except IOError:
+            return False
+
+    def isdir(self, path, follow_symlink=False):
+        """Check, that path is directory using SFTP session
+
+        :type path: str
+        :type follow_symlink: bool (default=False), resolve symlinks
+        :rtype: bool
+        """
+        try:
+            if follow_symlink:
+                attrs = self._sftp.stat(path)
+            else:
+                attrs = self._sftp.lstat(path)
+            return attrs.st_mode & stat.S_IFDIR != 0
+        except IOError:
+            return False
+
+    def walk(self, path):
+        files=[]
+        folders=[]
+        try:
+            for item in self._sftp.listdir_iter(path):
+                if item.st_mode & stat.S_IFDIR:
+                    folders.append(item.filename)
+                else:
+                    files.append(item.filename)
+        except IOError as e:
+            print("Error opening directory {0}: {1}".format(path, e))
+
+        yield path, folders, files
+        for folder in folders:
+            for res in self.walk(os.path.join(path, folder)):
+                yield res
+
+
+class SSHClientError(Exception):
+    """Base class for errors"""
+
+
+class SSHCalledProcessError(SSHClientError):
+    @staticmethod
+    def _makestr(data):
+        if isinstance(data, six.binary_type):
+            return data.decode('utf-8', errors='backslashreplace')
+        elif isinstance(data, six.text_type):
+            return data
+        else:
+            return repr(data)
+
+    def __init__(
+            self, command, returncode, expected=0, stdout=None, stderr=None):
+        self.returncode = returncode
+        self.expected = expected
+        self.cmd = command
+        self.stdout = stdout
+        self.stderr = stderr
+        message = (
+            "Command '{cmd}' returned exit code {code} while "
+            "expected {expected}".format(
+                cmd=self._makestr(self.cmd),
+                code=self.returncode,
+                expected=self.expected
+            ))
+        if self.stdout:
+            message += "\n\tSTDOUT:\n{}".format(self._makestr(self.stdout))
+        if self.stderr:
+            message += "\n\tSTDERR:\n{}".format(self._makestr(self.stderr))
+        super(SSHCalledProcessError, self).__init__(message)
+
+    @property
+    def output(self):
+        warnings.warn(
+            'output is deprecated, please use stdout and stderr separately',
+            DeprecationWarning)
+        return self.stdout + self.stderr
+
+
+class SSHTimeoutError(SSHClientError):
+    pass
+
+
+__all__ = ['SSHAuth', 'SSHClient', 'SSHClientError', 'SSHCalledProcessError', 'SSHTimeoutError']