| |
@@ -1,363 +0,0 @@
|
| |
- # -*- coding: utf-8 -*-
|
| |
- # Copyright 2009-2015, Red Hat, Inc.
|
| |
- # License: GPL-2.0+ <http://spdx.org/licenses/GPL-2.0+>
|
| |
- # See the LICENSE file for more details on Licensing
|
| |
-
|
| |
- '''Tools for remote execution primary for disposable clients'''
|
| |
-
|
| |
- from __future__ import absolute_import
|
| |
- import os
|
| |
- import os.path
|
| |
- import sys
|
| |
- import socket
|
| |
- import stat
|
| |
- import time
|
| |
- import pipes
|
| |
- import tarfile
|
| |
- import tempfile
|
| |
-
|
| |
- import paramiko
|
| |
-
|
| |
- from .logger import log
|
| |
- from . import exceptions as exc
|
| |
-
|
| |
- from libtaskotron.directives import exitcode_directive
|
| |
- from libtaskotron import file_utils
|
| |
-
|
| |
-
|
| |
- class ParamikoWrapper(object):
|
| |
- '''Wrapper for SSH communication using paramiko library'''
|
| |
-
|
| |
- #: timeout for network operations in seconds (900 seconds = 15 minutes)
|
| |
- TIMEOUT = 900
|
| |
-
|
| |
- def __init__(self, hostname, port, username, key_filename, stdio_filename=None):
|
| |
- self.ssh = None
|
| |
- self.sftp = None
|
| |
- self.hostname = hostname
|
| |
- self.port = port
|
| |
- self.username = username
|
| |
- self.key_filename = key_filename
|
| |
- self.stdio_filename = stdio_filename
|
| |
- self.outstream = file_utils.Tee(sys.stdout)
|
| |
-
|
| |
- def __enter__(self):
|
| |
- self.connect()
|
| |
- return self
|
| |
-
|
| |
- def __exit__(self, type, value, traceback):
|
| |
- self.close()
|
| |
-
|
| |
- def __str__(self):
|
| |
- return '<%s: %s@%s:%s>' % (self.__class__.__name__, self.username,
|
| |
- self.hostname, self.port)
|
| |
-
|
| |
- def connect(self):
|
| |
- '''Connect to a machine over ssh. Open sftp channel and, if applicable, file that
|
| |
- stdout/err from the machine will be saved to.
|
| |
-
|
| |
- :raise TaskotronRemoteError: when the connection does not succeed'''
|
| |
-
|
| |
- self.ssh = paramiko.SSHClient()
|
| |
- # accept unknown hosts
|
| |
- self.ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
| |
-
|
| |
- log.debug('Connecting to remote host: %s@%s:%s', self.username, self.hostname, self.port)
|
| |
-
|
| |
- try:
|
| |
- self.ssh.connect(self.hostname,
|
| |
- port=self.port,
|
| |
- username=self.username,
|
| |
- # we use empty string by default (no special key), but paramiko
|
| |
- # requires receiving None in that case
|
| |
- key_filename=self.key_filename or None)
|
| |
-
|
| |
- self.sftp = self.ssh.open_sftp()
|
| |
- self.sftp.get_channel().settimeout(self.TIMEOUT)
|
| |
-
|
| |
- if self.stdio_filename is not None:
|
| |
- try:
|
| |
- f = open(self.stdio_filename, 'w')
|
| |
- self.outstream.add(f)
|
| |
- except IOError, e:
|
| |
- log.warning('Could not open %s. Falling back to writing vm\'s output '
|
| |
- 'to stdout only.', self.stdio_filename)
|
| |
- except paramiko.BadHostKeyException, e:
|
| |
- raise exc.TaskotronRemoteError('Server\'s (%s@%s:%s) hostkey could not be verified: %s'
|
| |
- % (self.username, self.hostname, self.port, str(e)))
|
| |
- except paramiko.AuthenticationException, e:
|
| |
- raise exc.TaskotronRemoteError('Authentication to %s@%s:%s failed: %s' %
|
| |
- (self.username, self.hostname, self.port, str(e)))
|
| |
- except (paramiko.SSHException, socket.error), e:
|
| |
- raise exc.TaskotronRemoteError('Could not connect to %s@%s:%s: %s' %
|
| |
- (self.username, self.hostname, self.port, str(e)))
|
| |
- except IOError as e:
|
| |
- # let's hope this does not occur in more situations
|
| |
- raise exc.TaskotronRemoteError("The private key '%s' could not be read: %s" %
|
| |
- (self.key_filename, str(e)))
|
| |
-
|
| |
- def close(self):
|
| |
- '''Close open connections and files.'''
|
| |
-
|
| |
- self.outstream.close()
|
| |
- self.sftp.close()
|
| |
- self.ssh.close()
|
| |
-
|
| |
- def cmd(self, cmd, debug=True):
|
| |
- '''Execute a command.
|
| |
-
|
| |
- :param str cmd: A command to be executed. Make sure you escape it properly to prevent shell
|
| |
- expansion, in case it is not desired.
|
| |
- :param bool debug: Whether print out debugging log messages about what's happening.
|
| |
- :returns: returncode of the command
|
| |
- :raise TaskotronRemoteProcessError: If the command has non-zero return code and it isn't a
|
| |
- code of the exitcode directive.
|
| |
- :raise TaskotronRemoteTimeoutError: If the remote hasn't sent any output for
|
| |
- :attr:`TIMEOUT`.
|
| |
- '''
|
| |
-
|
| |
- if debug:
|
| |
- log.debug('Running command on remote host: %s', cmd)
|
| |
- # write the executed command also to stdio log, so that it's clear what's happening
|
| |
- # when reading it
|
| |
- self.outstream.write('$ %s\n' % cmd)
|
| |
-
|
| |
- # bufsize=1 means line buffering, which should improve the chance of receiving complete
|
| |
- # lines. get_pty=True allocates a pseudo-terminal, which is needed to have stdout and
|
| |
- # stderr interlaced properly (in the exact chronological order)
|
| |
- stdin, stdout, _ = self.ssh.exec_command(cmd, timeout=self.TIMEOUT, bufsize=1,
|
| |
- get_pty=True)
|
| |
-
|
| |
- # stdout.channel represents channel for both stdout and stderr
|
| |
- channel = stdout.channel
|
| |
-
|
| |
- # there seems to be no way to tell whether the remote is alive,
|
| |
- # so just use the counter
|
| |
- # https://phab.qa.fedoraproject.org/T593
|
| |
- alive_counter = 0
|
| |
- while not channel.exit_status_ready():
|
| |
- if channel.recv_ready():
|
| |
- data = channel.recv(65536)
|
| |
- self.outstream.write(data)
|
| |
- alive_counter = 0
|
| |
-
|
| |
- time.sleep(0.1)
|
| |
- alive_counter += 0.1
|
| |
-
|
| |
- if alive_counter >= self.TIMEOUT:
|
| |
- raise exc.TaskotronRemoteTimeoutError(
|
| |
- 'No output received from machine %s@%s:%s for %d seconds while running: %s' %
|
| |
- (self.username, self.hostname, self.port, self.TIMEOUT, cmd))
|
| |
-
|
| |
- retcode = channel.recv_exit_status()
|
| |
- # reprint the rest of data that was left in the buffer
|
| |
- self.outstream.write(stdout.read())
|
| |
-
|
| |
- if retcode != 0 and retcode != exitcode_directive.FAILURE:
|
| |
- raise exc.TaskotronRemoteProcessError('Command "%s" on %s@%s exited with code %s' %
|
| |
- (cmd, self.username, self.hostname, retcode))
|
| |
- return retcode
|
| |
-
|
| |
- def install_pkgs(self, pkgs):
|
| |
- '''Install packages via dnf.
|
| |
-
|
| |
- First tries to install packages using ``dnf --cacheonly --best``. Running from cache
|
| |
- improves performance, while ``--best`` ensures updating to the latest version. If this
|
| |
- doesn't work for some reason, we try again with refreshed cache, and if that still
|
| |
- doesn't work, we drop ``--best`` to allow for broken deps of latest packages, if there
|
| |
- are older packages with working deps.
|
| |
-
|
| |
- :param list pkgs: A list of packages to be installed (supports any argument that
|
| |
- ``dnf install`` accepts)
|
| |
- :raise TaskotronRemoteError: If the command has non-zero return code or times out (see
|
| |
- :meth:`cmd`).
|
| |
- '''
|
| |
-
|
| |
- pkgs_esc = [pipes.quote(pkg) for pkg in pkgs]
|
| |
- log.info('Installing %d packages on remote host...', len(pkgs))
|
| |
- try:
|
| |
- self.cmd('dnf --assumeyes --cacheonly --best install %s' % ' '.join(pkgs_esc))
|
| |
- except exc.TaskotronRemoteProcessError as e:
|
| |
- log.debug('Installation failed: %s', e)
|
| |
- log.debug('Trying again with forced metadata refresh...')
|
| |
- try:
|
| |
- self.cmd('dnf --assumeyes --refresh --best install %s' % ' '.join(pkgs_esc))
|
| |
- except exc.TaskotronRemoteError as e:
|
| |
- log.debug('Installation failed: %s', e)
|
| |
- log.debug('Trying again without --best...')
|
| |
- self.cmd('dnf --assumeyes install %s' % ' '.join(pkgs_esc))
|
| |
-
|
| |
- def write_file(self, remote_path, data, overwrite=True, debug=True):
|
| |
- '''Write data to a remote file.
|
| |
-
|
| |
- :param str remote_path: A path to the remote file
|
| |
- :param str data: Data to be written
|
| |
- :param bool overwrite: Whether to overwrite remote path. Default is True.
|
| |
- :param bool debug: Whether print out debugging log messages about what's happening.
|
| |
- :raise TaskotronRemoteError: If data could not be written
|
| |
- '''
|
| |
-
|
| |
- if debug:
|
| |
- log.debug('Writing data to %s@%s:%s ...', self.username, self.hostname, remote_path)
|
| |
-
|
| |
- # keep the conditionals in this order to avoid unnecessary remote calls
|
| |
- if not overwrite and self._remote_file_exists(remote_path):
|
| |
- log.info('Remote path %s already exists, not overwriting.', remote_path)
|
| |
- return
|
| |
-
|
| |
- try:
|
| |
- with self.sftp.open(remote_path, 'w') as remote_file:
|
| |
- remote_file.write(data)
|
| |
- except (socket.error, IOError) as e:
|
| |
- raise exc.TaskotronRemoteError('Could not write data to %s@%s:%s: %s' %
|
| |
- (self.username, self.hostname, remote_path, e))
|
| |
-
|
| |
- def put_file(self, local_path, remote_path, overwrite=True, debug=True):
|
| |
- '''Copy a file to a remote path. File permissions are not preserved.
|
| |
-
|
| |
- :param str local_path: A path to the local file
|
| |
- :param str remote_path: A path to the remote file
|
| |
- :param bool overwrite: Whether to overwrite remote path. Default is True.
|
| |
- :param bool debug: Whether print out debugging log messages about what's happening.
|
| |
- :return: :class:`paramiko.SFTPAttributes` object containing attributes about the given
|
| |
- file, if successful. ``None`` otherwise.
|
| |
- :raise TaskotronRemoteError: If the file could not be copied
|
| |
- '''
|
| |
- if debug:
|
| |
- log.debug('Copying %s to %s@%s:%s ...', local_path, self.username, self.hostname,
|
| |
- remote_path)
|
| |
-
|
| |
- # keep the conditionals in this order to avoid unnecessary remote calls
|
| |
- if not overwrite and self._remote_file_exists(remote_path):
|
| |
- if debug:
|
| |
- log.debug('Remote path %s already exists, not overwriting.', remote_path)
|
| |
- return
|
| |
-
|
| |
- try:
|
| |
- return self.sftp.put(local_path, remote_path)
|
| |
- except (socket.error, IOError) as e:
|
| |
- raise exc.TaskotronRemoteError('Could not put file %s to %s@%s:%s: %s' %
|
| |
- (local_path, self.username, self.hostname,
|
| |
- remote_path, e))
|
| |
-
|
| |
- def get_file(self, remote_path, local_path, debug=True):
|
| |
- '''Get a file from a remote path. File permissions are not preserved.
|
| |
-
|
| |
- :param str remote_path: A path to the remote file
|
| |
- :param str local_path: A path to the local file
|
| |
- :param bool debug: Whether print out debugging log messages about what's happening.
|
| |
- :raise TaskotronRemoteError: If the file could not be downloaded
|
| |
- '''
|
| |
-
|
| |
- if debug:
|
| |
- log.debug('Copying %s@%s:%s to %s ...', self.username, self.hostname, remote_path,
|
| |
- local_path)
|
| |
-
|
| |
- try:
|
| |
- self.sftp.get(remote_path, local_path)
|
| |
- except (socket.error, IOError) as e:
|
| |
- raise exc.TaskotronRemoteError('Could not copy file %s@%s:%s to %s: %s' %
|
| |
- (self.username, self.hostname, remote_path,
|
| |
- local_path, e))
|
| |
-
|
| |
- def _remote_file_exists(self, remote_path):
|
| |
- try:
|
| |
- path_stats = self.sftp.lstat(remote_path)
|
| |
- if path_stats is not None:
|
| |
- return True
|
| |
- except IOError:
|
| |
- pass
|
| |
-
|
| |
- return False
|
| |
-
|
| |
- def _remote_isdir(self, remote_path):
|
| |
- '''Return ``True`` if ``remote_path`` exists and is a directory, otherwise ``False`` (so
|
| |
- e.g. even if it exists, but is a file).'''
|
| |
- try:
|
| |
- return stat.S_ISDIR(self.sftp.lstat(remote_path).st_mode)
|
| |
- except IOError:
|
| |
- # when the dir doesn't exist: IOError: [Errno 2] No such file
|
| |
- return False
|
| |
-
|
| |
- def put_dir(self, local_path, remote_path, overwrite=True, debug=True):
|
| |
- '''Copy a directory to a remote path. This method creates a tarball from contents of
|
| |
- ``local_path``, copies the tarball to remote machine and extracts it to ``remote_path``.
|
| |
- File permissions and symlinks are preserved.
|
| |
-
|
| |
- :param str remote_path: A path to the remote directory. This directory will be created, if
|
| |
- its parent exists. Otherwise you need to create the full tree
|
| |
- structure manually beforehand.
|
| |
- :param str local_path: A path to the local directory
|
| |
- :param bool overwrite: Whether to overwrite remote path (merge local dir with the remote
|
| |
- dir). If you choose to not overwrite and ``remote_path`` exists,
|
| |
- this method with just immediately return.
|
| |
- :param bool debug: Whether print out debugging log messages about what's happening.
|
| |
- :raise TaskotronRemoteError: If the directory could not be copied
|
| |
- '''
|
| |
- if debug:
|
| |
- log.debug('Copying %s to %s@%s:%s ...', local_path, self.username,
|
| |
- self.hostname, remote_path)
|
| |
-
|
| |
- # keep the conditionals in this order to avoid unnecessary remote calls
|
| |
- if not overwrite and self._remote_file_exists(remote_path):
|
| |
- if debug:
|
| |
- log.debug('Remote path %s already exists, not overwriting.', remote_path)
|
| |
- return
|
| |
-
|
| |
- try:
|
| |
- if not self._remote_isdir(remote_path):
|
| |
- self.sftp.mkdir(remote_path)
|
| |
-
|
| |
- files_to_copy = os.listdir(local_path)
|
| |
- tar_local_path = tempfile.mktemp()
|
| |
- tar_remote_path = os.path.join(remote_path, 'tarred_files.tar')
|
| |
-
|
| |
- with tarfile.open(tar_local_path, 'w') as tar:
|
| |
- for filename in files_to_copy:
|
| |
- tar.add(os.path.join(local_path, filename), arcname=filename)
|
| |
-
|
| |
- self.put_file(tar_local_path, tar_remote_path, debug=False)
|
| |
- self.cmd('tar xf %s -C %s' % (pipes.quote(tar_remote_path), pipes.quote(remote_path)),
|
| |
- debug=False)
|
| |
-
|
| |
- os.remove(tar_local_path)
|
| |
- self.cmd('rm -f %s' % pipes.quote(tar_remote_path), debug=False)
|
| |
-
|
| |
- except (OSError, IOError) as e:
|
| |
- log.exception(e)
|
| |
- raise exc.TaskotronRemoteError('Could not copy dir %s to %s@%s:%s: %s' %
|
| |
- (local_path, self.username, self.hostname,
|
| |
- remote_path, e))
|
| |
-
|
| |
- def get_dir(self, remote_path, local_path, debug=True):
|
| |
- '''Get a directory from a remote path. File permissions are not preserved.
|
| |
-
|
| |
- :param str remote_path: A path to the remote directory
|
| |
- :param str local_path: A path to the local directory. This directory will be created, if
|
| |
- its parent exists. Otherwise you need to create the full tree
|
| |
- structure manually beforehand.
|
| |
- :param bool debug: Whether print out debugging log messages about what's happening.
|
| |
- :raise TaskotronRemoteError: If the directory could not be downloaded
|
| |
- '''
|
| |
-
|
| |
- if debug:
|
| |
- log.debug('Recursively copying %s@%s:%s to %s ...', self.username, self.hostname,
|
| |
- remote_path, local_path)
|
| |
-
|
| |
- try:
|
| |
- if not os.path.isdir(local_path):
|
| |
- os.mkdir(local_path)
|
| |
-
|
| |
- fileattrs = self.sftp.listdir_attr(remote_path)
|
| |
-
|
| |
- for fileattr in fileattrs:
|
| |
- remote_file = os.path.join(remote_path, fileattr.filename)
|
| |
- local_file = os.path.join(local_path, fileattr.filename)
|
| |
- if stat.S_ISDIR(fileattr.st_mode):
|
| |
- self.get_dir(remote_file, local_file, debug=False)
|
| |
- else:
|
| |
- self.get_file(remote_file, local_file, debug=False)
|
| |
- except (OSError, IOError) as e:
|
| |
- raise exc.TaskotronRemoteError('Could not copy dir %s@%s:%s to %s: %s' %
|
| |
- (self.username, self.hostname, remote_path,
|
| |
- local_path, e))
|
| |
This patch builds off of Martin's ansible tasks commit (rLTRN2b720ca61cb8).
I've added a Dockerfile in the 'libtaskotron/ext/' directory as
well as a module to 'libtaskotron/ext/disposable' for dealing with
Docker containers.
This expects a docker daemon to be running on the host (so is akin
to --libvirt), and will build the 'taskotron-worker' image if it's
not already found on the system.
Testing this is very similar to testing the patch Martin wrote,
except you use the '--docker' flag during invocation.
To test:
$ git clone https://github.com/stefwalter/gzip-dist-git
$ sudo runtask -d -i gzip -t koji_build -a x86_64 --docker gzip-dist-git/tests/test_rpm.yml
Read the whole conversation at https://fedorapeople.org/groups/qa/phabarchive/differentials/phab.qa.fedoraproject.org/D1196.html