diff --git a/docs/source/library.rst b/docs/source/library.rst
--- a/docs/source/library.rst
+++ b/docs/source/library.rst
@@ -10,13 +10,24 @@
 Note that the docs here are for libtaskotron library functions and don't
 include directives or task yaml details.
 
+arch_utils
+==========
+
+.. automodule:: libtaskotron.arch_utils
+   :members:
 
 bodhi_utils
 ===========
 
 .. automodule:: libtaskotron.bodhi_utils
    :members:
 
+buildbot_utils
+==============
+
+.. automodule:: libtaskotron.buildbot_utils
+  :members:
+
 check
 =====
 
@@ -41,6 +52,12 @@
 .. automodule:: libtaskotron.exceptions
    :members:
 
+executor
+========
+
+.. automodule:: libtaskotron.executor
+   :members:
+
 file_utils
 ==========
 
@@ -59,26 +76,50 @@
 .. automodule:: libtaskotron.logger
    :members:
 
+main
+====
+
+.. automodule:: libtaskotron.main
+   :members:
+
+minion
+======
+
+.. automodule:: libtaskotron.minion
+  :members:
+
+overlord
+========
+
+.. automodule:: libtaskotron.overlord
+   :members:
+
 python_utils
 ============
 
 .. automodule:: libtaskotron.python_utils
    :members:
 
+remote_exec
+===========
+
+.. automodule:: libtaskotron.remote_exec
+   :members:
+
 rpm_utils
 =========
 
 .. automodule:: libtaskotron.rpm_utils
    :members:
 
-runner
-======
+taskformula
+===========
 
-.. automodule:: libtaskotron.runner
+.. automodule:: libtaskotron.taskformula
    :members:
 
 vm
-==============
+==
 
 .. automodule:: libtaskotron.vm
    :members:
diff --git a/libtaskotron/exceptions.py b/libtaskotron/exceptions.py
--- a/libtaskotron/exceptions.py
+++ b/libtaskotron/exceptions.py
@@ -34,3 +34,8 @@
 class TaskotronRemoteError(TaskotronError):
     '''All network and remote-server related errors'''
     pass
+
+
+class TaskotronNotImplementedError(TaskotronError, NotImplementedError):
+    '''NotImplementedError for Taskotron classes, methods and functions'''
+    pass
diff --git a/libtaskotron/executor.py b/libtaskotron/executor.py
new file mode 100644
--- /dev/null
+++ b/libtaskotron/executor.py
@@ -0,0 +1,201 @@
+# -*- 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
+
+from __future__ import absolute_import
+import tempfile
+import os.path
+import imp
+import copy
+import collections
+import pipes
+
+from libtaskotron import taskformula
+from libtaskotron import logger
+from libtaskotron import python_utils
+from libtaskotron import config
+from libtaskotron import rpm_utils
+from libtaskotron.logger import log
+import libtaskotron.exceptions as exc
+from libtaskotron.directives import exitcode_directive
+
+
+class Executor(object):
+    '''This class serves the purpose of actual task execution. It is instantiated by
+    :class:`.Overlord`, :class:`.PersistentMinion` or :class:`.DisposableMinion`.
+    '''
+
+    def __init__(self, task_data, arg_data, workdir=None):
+        '''Create a new instance.
+
+        :param dict task_data: data loaded from task formula
+        :param dict arg_data: data from command line arguments
+        :param str workdir: path to working directory, temporary is created if None
+        '''
+
+        self.task_data = task_data
+        self.arg_data = arg_data
+        self.working_data = {}
+        self.directives = {}
+        self.workdir = workdir
+        #: int exitcode set by exitcode directive, is None if exitcode directive was not used
+        self.exitcode = None
+
+    def execute(self):
+        '''Execute the task (consists of preparing the task and running it).
+        '''
+        filelog_path = os.path.join(self.arg_data['artifactsdir'], 'taskotron.log')
+        logger.add_filehandler(filelog_path=filelog_path, remove_mem_handler=True)
+
+        self._prepare_task()
+        self._run()
+
+    def _prepare_task(self):
+        '''Prepare the environment for running a task. If there are packages, requirements or
+        groups specified in the task formula, this method tries to install them (in case of
+        production profile) or checks whether they are installed (in case of developer profile).
+        '''
+        rpms = self.task_data.get('environment', {}).get('rpm', [])
+
+        profile = config.get_config().profile
+
+        if profile == config.ProfileName.PRODUCTION or self.arg_data['local']:
+            rpm_utils.install(rpms)
+        else:
+            if not rpm_utils.is_installed(rpms):
+                raise exc.TaskotronError("Some packages are not installed. Please run "
+                                         "'dnf install %s' to install all required packages."
+                                         % " ".join([pipes.quote(rpm) for rpm in rpms]))
+
+    def _run(self):
+        self._validate_input()
+
+        if not self.workdir:  # create temporary workdir if needed
+            self.workdir = tempfile.mkdtemp(prefix="task-",
+                                            dir=config.get_config().tmpdir)
+        log.debug("Current workdir: %s", self.workdir)
+        self.arg_data['workdir'] = self.workdir
+        self.arg_data['checkname'] = self.task_data['name']
+
+        # override variable values
+        for var, val in self.arg_data['override']:
+            log.debug("Overriding variable %s, new value: %s", var, val)
+            self.arg_data[var] = eval(val, {}, {})
+
+        self._do_actions()
+
+    def _validate_input(self):
+        if 'input' not in self.task_data:
+            return
+
+        if not isinstance(self.task_data['input'], collections.Mapping):
+            raise exc.TaskotronYamlError("Input yaml should contain correct 'input'"
+                                         "section (a mapping). Yours was: %s" %
+                                         type(self.task_data['input']))
+
+        required_args = self.task_data['input'].get('args', None)
+
+        if not python_utils.iterable(required_args):
+            raise exc.TaskotronYamlError("Input yaml should contain correct 'args' "
+                                         "section (an iterable). Yours was: %s" %
+                                         type(required_args))
+
+        for arg in required_args:
+            if arg not in self.arg_data:
+                raise exc.TaskotronYamlError("Required input arg '%s' "
+                                             "was not defined" % arg)
+
+    def _do_actions(self):
+        '''Sequentially run all actions for a task. An 'action' is a single step
+        under the ``actions:`` key. An example action looks like::
+
+          - name: download rpms from koji
+            koji:
+              action: download
+              koji_build: $koji_build
+              arch: $arch
+        '''
+        if 'actions' not in self.task_data or not self.task_data['actions']:
+            raise exc.TaskotronYamlError("At least one task should be specified"
+                                         " in input formula")
+
+        for action in self.task_data['actions']:
+            self._do_single_action(action)
+
+    def _do_single_action(self, action):
+        '''Execute a single action from the task. See :meth:`do_actions` to see
+        how an action looks like.
+
+        :param dict action: An action specification parsed from the task formula
+        '''
+        directive_name = self._extract_directive_from_action(action)
+
+        rendered_action = self._render_action(action)
+
+        log.debug("Executing directive: %s", directive_name)
+        self._load_directive(directive_name)
+
+        directive_object = self.directives[directive_name]
+        directive_classname = directive_object.directive_class
+
+        directive_callable = getattr(directive_object, directive_classname)()
+
+        output = directive_callable.process(rendered_action[directive_name],
+                                            self.arg_data)
+
+        if 'export' in action:
+            self.working_data[action['export']] = output
+            log.debug("Variable ${%s} was exported with value:\n%s" %
+                      (action['export'], output))
+
+        # unable to use isinstance() due to dynamically loaded directives
+        if directive_classname == exitcode_directive.directive_class:
+            # set exitcode if was None or overwrite previous SUCCESS with FAILURE (not vice versa)
+            if not self.exitcode or output != exitcode_directive.SUCCESS:
+                log.info("Setting exitcode to %s" % output)
+                self.exitcode = output
+
+    def _extract_directive_from_action(self, action):
+        for key in action:
+            if key not in ['name', 'export']:
+                return key
+        raise exc.TaskotronYamlError('no directive found in action %s' % str(action))
+
+    def _render_action(self, action):
+        '''Take an action and replace all included variables with actual values
+        from :attr:`env_data` and :attr:`working_data`. See :meth:`do_actions`
+        to see how an action looks like.
+
+        :param dict action: An action specification parsed from the task formula
+        :return: a rendered action
+        :rtype: dict
+        '''
+        # copy the input so that we don't disrupt what we're processing
+        rendered_action = copy.deepcopy(action)
+
+        variables = copy.deepcopy(self.arg_data)
+        variables.update(self.working_data)
+        taskformula.replace_vars_in_action(rendered_action, variables)
+
+        return rendered_action
+
+    def _load_directive(self, directive_name, directive_dir=None):
+        # look in default path if nothing is specified
+        if not directive_dir:
+            directive_dir = os.path.join(os.path.dirname(__file__),
+                                         'directives')
+
+        real_name = "%s_directive" % directive_name
+        directive_file = os.path.join(directive_dir, '%s.py' % real_name)
+
+        if not os.path.exists(directive_file):
+            raise exc.TaskotronYamlError("Directive %s not found in directory %s" %
+                                         (directive_name, directive_dir))
+
+        loaded_directive = imp.load_source(real_name, directive_file)
+        self.directives[directive_name] = loaded_directive
+
+    def _validate_env(self):
+        # TODO: implement this
+        raise NotImplementedError("Environment validation is not yet implemented")
diff --git a/libtaskotron/file_utils.py b/libtaskotron/file_utils.py
--- a/libtaskotron/file_utils.py
+++ b/libtaskotron/file_utils.py
@@ -152,3 +152,27 @@
             raise
 
     return dest
+
+
+# http://stackoverflow.com/questions/11325019
+class Tee(object):
+    '''Helper class for writing data to different streams.'''
+
+    def __init__(self, *files):
+        self._files = list(files)
+
+    def add(self, file_):
+        if file_.mode.startswith('w'):
+            self._files.append(file_)
+        else:
+            name = file_.name if hasattr(file_, 'name') else '<unnamed file>'
+            log.warning('File %s not opened for writing. Not adding.', name)
+
+    def write(self, data):
+        for f in self._files:
+            f.write(data)
+
+    def close(self):
+        for f in self._files:
+            if f is not sys.stdout:
+                f.close()
diff --git a/libtaskotron/main.py b/libtaskotron/main.py
new file mode 100644
--- /dev/null
+++ b/libtaskotron/main.py
@@ -0,0 +1,158 @@
+# -*- 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
+
+from __future__ import absolute_import
+import logging
+import os.path
+import argparse
+import datetime
+import sys
+
+import libtaskotron
+from libtaskotron import logger
+from libtaskotron import config
+from libtaskotron.logger import log
+from libtaskotron.overlord import Overlord
+
+
+# The list of accepted item types on the command line (--type option)
+_ITEM_TYPES = ["bodhi_id", "koji_build", "koji_tag", "compose"]
+
+
+def get_argparser():
+    parser = argparse.ArgumentParser()
+    parser.add_argument("task", nargs=1, help="task to run")
+    parser.add_argument("-a", "--arch",
+                        choices=["i386", "x86_64", "armhfp", "noarch"],
+                        action='append',
+                        help="architecture specifying the item to be checked. If omitted, "
+                             "defaults to noarch.")
+    parser.add_argument("-i", "--item", help="item to be checked")
+    parser.add_argument("-t", "--type",
+                        choices=_ITEM_TYPES,
+                        help="type of --item argument")
+    parser.add_argument("-j", "--jobid", default="-1",
+                        help="optional job identifier used to render log urls")
+    parser.add_argument("-d", "--debug", action="store_true",
+                        help="Enable debug output "
+                             "(set logging level to 'DEBUG')")
+    parser.add_argument("--uuid", default=datetime.datetime.utcnow().strftime("%Y%m%d_%H%M%S_%f"),
+                        help="Unique job identifier for the execution"
+                             "status tracking purposes. If unset, defaults to"
+                             "current datetime in UTC")
+    parser.add_argument("--override", action="append", default=[], metavar='VAR=VALUE',
+                        help="override internal variable values used in runner "
+                             "and the task formula. Value itself is evaluated "
+                             "by eval(). This option can be used multiple times. "
+                             "Example: --override \"workdir='/some/dir/'\"")
+    parser.add_argument("-p", "--patch", help="patch to apply to remote system"
+                        "before execution of task")
+    parser.add_argument("--local", action="store_true",
+                        help="make the task run locally on this very machine (the default "
+                             "behavior for development profile). This also approves any required "
+                             "system-wide changes to be performed (automatic installation of "
+                             "package dependencies, destructive tasks allowed, etc).")
+    parser.add_argument('--libvirt', action='store_true',
+                        help="make the task run remotely in a disposable client spawned using "
+                        "libvirt (the default behavior for production profile).")
+    parser.add_argument("--ssh", metavar='user@machine[:port]',
+                        help="make the task run on a remote machine over ssh")
+    parser.add_argument("--ssh-privkey", metavar="</path/to/private.key>",
+                        help="path to private key for remote connections over ssh")
+    parser.add_argument("--no-destroy", action="store_true",
+                        help="do not destroy disposable client at the end of task execution")
+
+    return parser
+
+
+def check_args(parser, args):
+    """ Check if passed args doesn't have conflicts and have proper format. In case of error, this
+    function prints error message and exits the program.
+
+    :param ArgumentParser parser: parser object used to show error message and exit the program
+    :param dict args: arguments previously returned by argument parser converted to dict
+    """
+
+    if len([arg for arg in [args['local'], args['libvirt'], args['ssh']] if arg]) > 1:
+        parser.error("Args --local, --libvirt and --ssh are mutually exclusive")
+
+    if args['ssh']:
+        if '@' not in args['ssh']:
+            parser.error("SSH connection info not in format 'user@machine' or 'user@machine:port'")
+
+
+def process_args(args):
+    """ Processes raw input args and converts them into specific data types that
+    can be used by tasks. This includes e.g. creating new args based on
+    (item, item_type) pairs, or adjusting selected architecture.
+
+    :param dict args: dictionary of raw arguments
+    :returns: dict of args with appended specific data
+    """
+
+    # process item + type
+    if args['type'] in _ITEM_TYPES:
+        args[args['type']] = args['item']
+
+    # process arch
+    if args['arch'] is None:
+        args['arch'] = ['noarch']
+
+    # using list of tuples instead of dictionary so the args['override'] is
+    # still list at the end of process_args() - for better testability
+    override = []
+    for var in args['override']:
+        name = var.split('=')[0]
+        value = var.split('=')[1]
+        override.append((name, value))
+    args['override'] = override
+
+    if args['ssh']:
+        args['user'], machine = args['ssh'].split('@')
+        if ':' in machine:
+            args['machine'], port = machine.split(':')
+            args['port'] = int(port)
+        else:
+            args['machine'] = machine
+            args['port'] = 22
+
+    args['artifactsdir'] = os.path.join(config.get_config().artifactsdir, args['uuid'])
+
+    return args
+
+
+def main():
+    # Preliminary initialization of logging, so all messages before regular
+    # initialization can be logged to stream.
+    logger.init_prior_config()
+
+    log.info('Execution started at: %s',
+             datetime.datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S UTC'))
+    log.debug('Using libtaskotron %s', libtaskotron.__version__)
+
+    # parse cmdline
+    parser = get_argparser()
+    args = parser.parse_args()
+
+    check_args(parser, vars(args))
+
+    log.debug('Parsed arguments: %s', args)
+
+    arg_data = process_args(vars(args))
+    arg_data['taskfile'] = args.task[0]
+
+    # initialize logging
+    level_stream = logging.DEBUG if args.debug else None
+    logger.init(level_stream=level_stream)
+
+    overlord = Overlord(arg_data)
+    overlord.start()
+
+    log.info('Execution finished at: %s. Task artifacts were saved in: %s',
+             datetime.datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S UTC'),
+             arg_data['artifactsdir'])
+
+    log.info("Exiting with exit status %d" % overlord.exitcode)
+    sys.exit(overlord.exitcode)
diff --git a/libtaskotron/minion.py b/libtaskotron/minion.py
new file mode 100644
--- /dev/null
+++ b/libtaskotron/minion.py
@@ -0,0 +1,179 @@
+# -*- 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
+
+from __future__ import absolute_import
+import os.path
+import yaml
+import pipes
+
+from libtaskotron import logger
+from libtaskotron import config
+from libtaskotron import remote_exec
+from libtaskotron import vm
+from libtaskotron.logger import log
+import libtaskotron.exceptions as exc
+
+
+class BaseMinion(object):
+    '''Base Minion class that shouldn't be used separately, it solely initiates inner attributes
+    and environment. It also provides method _run() that delegates the task execution to Executor
+    over SSH.
+    '''
+
+    def __init__(self, task_data, arg_data):
+        '''Create a new instance.
+
+        :param dict task_data: data loaded from task formula
+        :param dict arg_data: data from command line arguments
+        '''
+
+        self.task_data = task_data
+        self.arg_data = arg_data
+        #: str uuid_dir with path to artifactsdir for storing logs
+        self.uuid_dir = self.arg_data['artifactsdir']
+
+        self.user = arg_data.get('user', None)
+        self.machine = arg_data.get('machine', None)
+        self.port = arg_data.get('port', None)
+        self.ssh_privkey = arg_data['ssh_privkey'] or config.get_config().ssh_privkey
+        #: int exitcode set by exitcode directive.
+        self.exitcode = None
+        self.ssh = None
+
+    def _prepare_task(self):
+        '''Prepare the environment to run the task.
+        '''
+
+        self.taskdir = config.get_config().client_taskdir
+
+        # FIXME remove this once the vm code is done
+        self.ssh.cmd('curl -o /etc/yum.repos.d/tflink-taskotron-fedora-f21.repo '
+                     'http://copr-fe.cloud.fedoraproject.org/coprs/tflink/taskotron/'
+                     'repo/fedora-21/tflink-taskotron-fedora-21.repo')
+        self.ssh.install_pkgs('libtaskotron')
+
+        # configure libtaskotron
+        if config.get_config().config_filename:
+            self.ssh.put_file(config.get_config().config_filename, '/etc/taskotron/taskotron.yaml')
+
+        # create needed dirs
+        self.ssh.mkdir(self.taskdir)
+
+        # patch remote libtaskotron if needed (for dev, shouldn't be used in production)
+        if self.arg_data['patch'] is not None:
+            self.ssh.put_file(os.path.abspath(self.arg_data['patch']), '%s/%s' % (
+                self.taskdir, self.arg_data['patch']))
+            self.ssh.install_pkgs('patch')
+            self.ssh.cmd('patch -d /usr/lib/python2.7/site-packages/ -p1 -i %s/%s' % (
+                self.taskdir, self.arg_data['patch']))
+
+        # put files needed for execution (task, input files, etc)
+        self.ssh.put_dir(os.path.dirname(self.arg_data['taskfile']), self.taskdir)
+
+        # need to have default_flow_style false to get valid yaml w/ nested dicts
+        self.ssh.write_file(os.path.join(self.taskdir,
+                                         os.path.basename(self.arg_data['taskfile'])),
+                            yaml.dump(self.task_data, default_flow_style=False))
+
+    def _run(self):
+        # execute task
+        task_cmd = ['cd', pipes.quote(self.taskdir), '&&',
+                    'runtask',
+                    '-t', pipes.quote(self.arg_data['type']),
+                    '-i', pipes.quote(self.arg_data['item']),
+                    '--uuid', pipes.quote(self.arg_data['uuid']),
+                    '--local',
+                    pipes.quote(os.path.basename(self.arg_data['taskfile']))]
+
+        self.exitcode = self.ssh.cmd(' '.join(task_cmd))
+
+    def _get_output(self):
+        '''copy artifacts from vm'''
+        try:
+            self.ssh.get_dir(self.arg_data['artifactsdir'], self.arg_data['artifactsdir'])
+        except exc.TaskotronRemoteError, e:
+            log.info(e)
+
+    def execute(self):
+        '''This method has to be implemented by classes that inherits from :class:`.BaseMinion`.
+        It should contain environment initialization, namely set `self.ssh` to properly
+        instantiated :class:`.ParamikoWrapper` and cleanup (if necessarily). Moreover, the order of
+        private methods has to be: `_prepare_task()`, `_run()` and `_get_output()`.
+        '''
+        raise exc.TaskotronNotImplementedError()
+
+
+class PersistentMinion(BaseMinion):
+    '''Minion class that connects to alredy running machine using SSH and runs the task there.
+    '''
+
+    def execute(self):
+        '''Init environment and connect to remote machine for task execution.'''
+
+        filelog_path = os.path.join(self.uuid_dir, 'taskotron-initiator.log')
+        logger.add_filehandler(filelog_path=filelog_path, remove_mem_handler=True)
+
+        stdio_filename = os.path.join(self.uuid_dir, 'taskotron-stdio.log')
+
+        log.info("Running task over SSH on machine '%s'...", self.machine)
+
+        with remote_exec.ParamikoWrapper(self.machine, self.port, self.user,
+                                         self.ssh_privkey, stdio_filename) as self.ssh:
+            try:
+                self._prepare_task()
+                self._run()
+            except exc.TaskotronRemoteError, e:
+                log.exception(e)
+
+            self._get_output()
+
+
+class DisposableMinion(BaseMinion):
+    '''Minion class that creates a disposable client, connects to it over SSH and runs the task
+    there.
+    '''
+
+    def execute(self):
+        '''Init environment and connect to disposable client for task execution.'''
+
+        filelog_path = os.path.join(self.uuid_dir, 'taskotron-initiator.log')
+        logger.add_filehandler(filelog_path=filelog_path, remove_mem_handler=True)
+
+        stdio_filename = os.path.join(self.uuid_dir, 'taskotron-stdio.log')
+
+        try:
+            log.info("Running task on a disposable client using libvirt...")
+
+            log.info("Creating VM for task")
+            task_vm = vm.TestCloudMachine(self.arg_data['uuid'])
+            task_vm.prepare()
+            self.machine = task_vm.ipaddr
+            self.port = 22
+            self.user = task_vm.username
+
+            log.info("Running Task over SSH on machine %s" % task_vm.instancename)
+
+            task_vm.wait_for_port(self.port)
+
+            with remote_exec.ParamikoWrapper(self.machine, self.port, self.user,
+                                             self.ssh_privkey, stdio_filename) as self.ssh:
+                try:
+                    self._prepare_task()
+                    self._run()
+                except exc.TaskotronRemoteError, e:
+                    log.exception(e)
+
+                self._get_output()
+        finally:
+            if self.arg_data['no_destroy']:
+                log.info("--no-destroy mode was set from command line, not tearing VM "
+                         "down for task %s" % self.arg_data['uuid'])
+            else:
+                try:
+                    task_vm.teardown()
+                except exc.TaskotronRemoteError, e:
+                    log.error("Hit problem during teardown of VM for task %s" %
+                              self.arg_data['uuid'])
+                    log.exception(e)
diff --git a/libtaskotron/overlord.py b/libtaskotron/overlord.py
new file mode 100644
--- /dev/null
+++ b/libtaskotron/overlord.py
@@ -0,0 +1,99 @@
+# -*- 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
+
+from __future__ import absolute_import
+
+from libtaskotron import config
+from libtaskotron.logger import log
+from libtaskotron import executor
+from libtaskotron import minion
+from libtaskotron import file_utils
+from libtaskotron import taskformula
+from libtaskotron import exceptions as exc
+from libtaskotron.directives import exitcode_directive
+
+
+class Overlord(object):
+    """Overlord class encapsulates decision whether the task is run locally or remotely (in
+    persistent or disposable client) and orchestrates the execution.
+    """
+
+    def __init__(self, arg_data):
+        """Create new instance of Overlord.
+
+        :param dict arg_data: data from command line arguments
+        """
+
+        self.arg_data = arg_data
+
+        #: int exitcode contains the exitcode of task, if None, method :func:`~Overlord.start` was
+        #: not called yet.
+        self.exitcode = None
+
+        # parse task formula
+        self.task_data = taskformula.parse_yaml_from_file(arg_data['taskfile'])
+        if not self.task_data:
+            raise exc.TaskotronYamlError('Input file should not be empty')
+
+    def _get_runner(self):
+        """Decide the run mode and get appropriate runner (Executor or Minion).
+
+        :returns: either :class:`.Executor` (local run mode) or :class:`.PersistentMinion` or
+                  :class:`.DisposableMinion` (remote run mode)
+        """
+        # run locally or remotely
+        run_remotely = False
+        # when running remotely, run directly over ssh, instead of using libvirt
+        persistent = False
+
+        # decide whether to run locally or remotely
+        runtask_mode = config.get_config().runtask_mode
+        if runtask_mode == config.RuntaskModeName.LOCAL:
+            run_remotely = False
+        elif runtask_mode == config.RuntaskModeName.LIBVIRT:
+            run_remotely = True
+        else:
+            assert False, 'This should never occur'
+
+        if self.arg_data['local']:
+            log.debug("Forcing local execution (option --local)")
+            run_remotely = False
+
+        elif self.arg_data['libvirt']:
+            log.debug("Forcing remote execution (option --libvirt)")
+            run_remotely = True
+            persistent = False
+
+        elif self.arg_data['ssh']:
+            log.debug('Forcing remote execution (option --ssh)')
+            run_remotely = True
+            persistent = True
+
+        log.debug('Execution mode: %s', 'remote' if run_remotely else 'local')
+
+        if run_remotely:
+            if persistent:
+                return minion.PersistentMinion(self.task_data, self.arg_data)
+            else:
+                return minion.DisposableMinion(self.task_data, self.arg_data)
+        else:
+            return executor.Executor(self.task_data, self.arg_data)
+
+    def start(self):
+        """Start the overlord, get runner and execute the task (either locally or remotely).
+        """
+        try:
+            file_utils.makedirs(self.arg_data['artifactsdir'])
+            log.info("Task artifacts will be saved in: %s", self.arg_data['artifactsdir'])
+        except OSError:
+            log.error("Can't create artifacts directory %s", self.arg_data['artifactsdir'])
+            raise
+
+        runner = self._get_runner()
+        runner.execute()
+
+        self.exitcode = 0
+        if runner.exitcode == exitcode_directive.FAILURE:
+            self.exitcode = runner.exitcode
diff --git a/libtaskotron/remote_exec.py b/libtaskotron/remote_exec.py
--- a/libtaskotron/remote_exec.py
+++ b/libtaskotron/remote_exec.py
@@ -19,33 +19,10 @@
 from . import exceptions as exc
 
 from libtaskotron.directives import exitcode_directive
+from libtaskotron.file_utils import Tee
 
 
-# http://stackoverflow.com/questions/11325019
-class Tee(object):
-    '''Helper class for writing data to different streams.'''
-
-    def __init__(self, *files):
-        self._files = list(files)
-
-    def add(self, file_):
-        if file_.mode.startswith('w'):
-            self._files.append(file_)
-        else:
-            name = file_.name if hasattr(file_, 'name') else '<unnamed file>'
-            log.warning('File %s not opened for writing. Not adding.', name)
-
-    def write(self, data):
-        for f in self._files:
-            f.write(data)
-
-    def close(self):
-        for f in self._files:
-            if f is not sys.stdout:
-                f.close()
-
-
-class RemoteExec(object):
+class ParamikoWrapper(object):
     '''Wrapper for SSH communication using paramiko library'''
 
     # timeout for network operations in seconds (900 seconds = 15 minutes)
diff --git a/libtaskotron/runner.py b/libtaskotron/runner.py
deleted file mode 100644
--- a/libtaskotron/runner.py
+++ /dev/null
@@ -1,528 +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
-
-from __future__ import absolute_import
-import logging
-import tempfile
-import os.path
-import argparse
-import imp
-import copy
-import collections
-import datetime
-import yaml
-import pipes
-import sys
-
-import libtaskotron
-from libtaskotron import taskformula
-from libtaskotron import logger
-from libtaskotron import python_utils
-from libtaskotron import file_utils
-from libtaskotron import config
-from libtaskotron import remote_exec
-from libtaskotron import rpm_utils
-from libtaskotron import vm
-from libtaskotron.logger import log
-import libtaskotron.exceptions as exc
-from libtaskotron.directives import exitcode_directive
-
-
-# The list of accepted item types on the command line (--type option)
-_ITEM_TYPES = ["bodhi_id", "koji_build", "koji_tag", "compose"]
-
-
-class Runner():
-    '''Runner class that decides whether the task should be run locally or remotely,
-    intializes variables (such as logger and ssh), instantiates Local or Remote Runner
-    and runs the task.
-    '''
-
-    def __init__(self, task_data, arg_data):
-        '''Create a new instance.
-
-        :param dict task_data: data loaded from task formula
-        :param dict arg_data: data from command line arguments
-        '''
-
-        self.task_data = task_data
-        self.arg_data = arg_data
-
-        #: run locally or remotely
-        self.run_remotely = False
-        #: when running remotely, run directly over ssh, instead of using libvirt
-        self.direct_ssh = False
-        self.user = self.machine = self.port = None
-        self.ssh_privkey = arg_data['ssh_privkey'] or config.get_config().ssh_privkey
-        #: int exitcode set by exitcode directive. If executed locally, it is None if exitcode
-        #: directive was not used.
-        self.exitcode = None
-
-        # decide whether to run locally or remotely
-        runtask_mode = config.get_config().runtask_mode
-        if runtask_mode == config.RuntaskModeName.LOCAL:
-            self.run_remotely = False
-        elif runtask_mode == config.RuntaskModeName.LIBVIRT:
-            self.run_remotely = True
-        else:
-            assert False, 'This should never occur'
-
-        if arg_data['local']:
-            log.debug("Forcing local execution (option --local)")
-            self.run_remotely = False
-
-        elif arg_data['libvirt']:
-            log.debug("Forcing remote execution (option --libvirt)")
-            self.run_remotely = True
-            self.direct_ssh = False
-
-        elif arg_data['ssh']:
-            log.debug('Forcing remote execution (option --ssh)')
-            self.run_remotely = True
-            self.direct_ssh = True
-
-            self.user, machine = arg_data['ssh'].split('@')
-            if ':' in machine:
-                self.machine, port = machine.split(':')
-                self.port = int(port)
-            else:
-                self.machine = machine
-                self.port = 22
-
-        log.debug('Execution mode: %s', 'remote' if self.run_remotely else 'local')
-
-    def run(self):
-        '''Run the apropriate code based on previous local/remote decision.'''
-        if self.run_remotely:
-            self._run_remotely()
-        else:
-            self._run_locally()
-
-    def _run_locally(self):
-        '''Init environment, instantiate LocalRunner and run it.'''
-
-        filelog_path = os.path.join(self.arg_data['artifactsdir'], 'taskotron.log')
-        logger.add_filehandler(filelog_path=filelog_path, remove_mem_handler=True)
-
-        task_runner = LocalRunner(self.task_data, self.arg_data)
-        task_runner.prepare_task()
-        task_runner.run()
-        self.exitcode = task_runner.exitcode
-
-    def _run_remotely(self):
-        '''Init environment, instantiate RemoteRunner and run it.'''
-
-        filelog_path = os.path.join(self.arg_data['artifactsdir'], 'taskotron-initiator.log')
-        logger.add_filehandler(filelog_path=filelog_path, remove_mem_handler=True)
-
-        stdio_filename = os.path.join(self.arg_data['artifactsdir'], 'taskotron-stdio.log')
-
-        try:
-            if self.direct_ssh:
-                log.info("Running task over SSH on machine '%s'...", self.machine)
-            else:
-                log.info("Running task on a disposable client using libvirt...")
-
-                log.info("Creating VM for task")
-                task_vm = vm.TestCloudMachine(self.arg_data['uuid'])
-                task_vm.prepare()
-                self.machine = task_vm.ipaddr
-                self.port = 22
-                self.user = task_vm.username
-
-                log.info("Running Task over SSH on machine %s" % task_vm.instancename)
-
-                task_vm.wait_for_port(self.port)
-
-            with remote_exec.RemoteExec(self.machine, self.port, self.user,
-                                        self.ssh_privkey, stdio_filename) as ssh:
-                task_runner = RemoteRunner(ssh, self.task_data, self.arg_data)
-                try:
-                    task_runner.prepare_task()
-                    task_runner.run()
-                except exc.TaskotronRemoteError, e:
-                    log.exception(e)
-
-                task_runner.get_output()
-                self.exitcode = task_runner.exitcode
-        finally:
-            # don't run teardown in the direct ssh case or when --no-destroy
-            if self.arg_data['no_destroy']:
-                log.info("--no-destroy mode was set from command line, not tearing VM "
-                         "down for task %s" % self.arg_data['uuid'])
-            elif not self.direct_ssh:
-                try:
-                    task_vm.teardown()
-                except exc.TaskotronRemoteError, e:
-                    log.error("Hit problem during teardown of VM for task %s" %
-                              self.arg_data['uuid'])
-                    log.exception(e)
-
-
-class LocalRunner(object):
-    def __init__(self, task_data, arg_data, workdir=None):
-        self.task_data = task_data
-        self.arg_data = arg_data
-        self.working_data = {}
-        self.directives = {}
-        self.workdir = workdir
-        #: int exitcode set by exitcode directive, is None if exitcode directive was not used
-        self.exitcode = None
-
-    def run(self):
-        self._validate_input()
-
-        if not self.workdir:  # create temporary workdir if needed
-            self.workdir = tempfile.mkdtemp(prefix="task-",
-                                            dir=config.get_config().tmpdir)
-        log.debug("Current workdir: %s", self.workdir)
-        self.arg_data['workdir'] = self.workdir
-        self.arg_data['checkname'] = self.task_data['name']
-
-        # override variable values
-        for var, val in self.arg_data['override']:
-            log.debug("Overriding variable %s, new value: %s", var, val)
-            self.arg_data[var] = eval(val, {}, {})
-
-        self.do_actions()
-
-    def _load_directive(self, directive_name, directive_dir=None):
-        # look in default path if nothing is specified
-        if not directive_dir:
-            directive_dir = os.path.join(os.path.dirname(__file__),
-                                         'directives')
-
-        real_name = "%s_directive" % directive_name
-        directive_file = os.path.join(directive_dir, '%s.py' % real_name)
-
-        if not os.path.exists(directive_file):
-            raise exc.TaskotronYamlError("Directive %s not found in directory %s" %
-                                         (directive_name, directive_dir))
-
-        loaded_directive = imp.load_source(real_name, directive_file)
-        self.directives[directive_name] = loaded_directive
-
-    def _render_action(self, action):
-        '''Take an action and replace all included variables with actual values
-        from :attr:`env_data` and :attr:`working_data`. See :meth:`do_actions`
-        to see how an action looks like.
-
-        :param dict action: An action specification parsed from the task formula
-        :return: a rendered action
-        :rtype: dict
-        '''
-        # copy the input so that we don't disrupt what we're processing
-        rendered_action = copy.deepcopy(action)
-
-        variables = copy.deepcopy(self.arg_data)
-        variables.update(self.working_data)
-        taskformula.replace_vars_in_action(rendered_action, variables)
-
-        return rendered_action
-
-    def _extract_directive_from_action(self, action):
-        for key in action:
-            if key not in ['name', 'export']:
-                return key
-        raise exc.TaskotronYamlError('no directive found in action %s' % str(action))
-
-    def do_single_action(self, action):
-        '''Execute a single action from the task. See :meth:`do_actions` to see
-        how an action looks like.
-
-        :param dict action: An action specification parsed from the task formula
-        '''
-        directive_name = self._extract_directive_from_action(action)
-
-        rendered_action = self._render_action(action)
-
-        log.debug("Executing directive: %s", directive_name)
-        self._load_directive(directive_name)
-
-        directive_object = self.directives[directive_name]
-        directive_classname = directive_object.directive_class
-
-        directive_callable = getattr(directive_object, directive_classname)()
-
-        output = directive_callable.process(rendered_action[directive_name], self.arg_data)
-
-        if 'export' in action:
-            self.working_data[action['export']] = output
-            log.debug("Variable ${%s} was exported with value:\n%s" % (action['export'], output))
-
-        # unable to use isinstance() due to dynamically loaded directives
-        if directive_classname == exitcode_directive.directive_class:
-            # set exitcode if was None or overwrite previous SUCCESS with FAILURE (not vice versa)
-            if not self.exitcode or output != exitcode_directive.SUCCESS:
-                log.info("Setting exitcode to %s" % output)
-                self.exitcode = output
-
-    def do_actions(self):
-        '''Sequentially run all actions for a task. An 'action' is a single step
-        under the ``actions:`` key. An example action looks like::
-
-          - name: download rpms from koji
-            koji:
-              action: download
-              koji_build: $koji_build
-              arch: $arch
-        '''
-        if 'actions' not in self.task_data or not self.task_data['actions']:
-            raise exc.TaskotronYamlError("At least one task should be specified in input formula")
-
-        for action in self.task_data['actions']:
-            self.do_single_action(action)
-
-    def _validate_input(self):
-        if 'input' not in self.task_data:
-            return
-
-        if not isinstance(self.task_data['input'], collections.Mapping):
-            raise exc.TaskotronYamlError("Input yaml should contain correct 'input'"
-                                         "section (a mapping). Yours was: %s"
-                                         % type(self.task_data['input']))
-
-        required_args = self.task_data['input'].get('args', None)
-
-        if not python_utils.iterable(required_args):
-            raise exc.TaskotronYamlError("Input yaml should contain correct 'args' "
-                "section (an iterable). Yours was: %s" % type(required_args))
-
-        for arg in required_args:
-            if arg not in self.arg_data:
-                raise exc.TaskotronYamlError("Required input arg '%s' "
-                                             "was not defined" % arg)
-
-    def _validate_env(self):
-        # TODO: implement this
-        raise NotImplementedError("Environment validation is not yet implemented")
-
-    def prepare_task(self):
-        '''Prepare the environment for running a task. If there are packages, requirements or
-        groups specified in the task formula, this method tries to install them (in case of
-        production profile) or checks whether they are installed (in case of developer profile).
-        '''
-        rpms = self.task_data.get('environment', {}).get('rpm', [])
-
-        profile = config.get_config().profile
-
-        if profile == config.ProfileName.PRODUCTION or self.arg_data['local']:
-            rpm_utils.install(rpms)
-        else:
-            if not rpm_utils.is_installed(rpms):
-                raise exc.TaskotronError("Some packages are not installed. Please run "
-                                         "'dnf install %s' to install all required packages."
-                                         % " ".join([pipes.quote(rpm) for rpm in rpms]))
-
-
-class RemoteRunner(object):
-    def __init__(self, ssh, task_data, arg_data):
-        self.ssh = ssh
-        self.task_data = task_data
-        self.arg_data = arg_data
-        self.taskdir = config.get_config().client_taskdir
-        #: int exitcode set by exitcode directive. It is always set to value returned by
-        #: self.ssh.cmd() in the run() method.
-        self.exitcode = None
-
-    def prepare_task(self):
-        # FIXME remove this once the vm code is done
-        self.ssh.cmd('curl -o /etc/yum.repos.d/tflink-taskotron-fedora-f21.repo '
-                     'http://copr-fe.cloud.fedoraproject.org/coprs/tflink/taskotron/'
-                     'repo/fedora-21/tflink-taskotron-fedora-21.repo')
-        self.ssh.cmd('curl -o /etc/yum.repos.d/tflink-taskotron-disposable-fedora-f21.repo '
-                     'https://copr-fe.cloud.fedoraproject.org/coprs/tflink/taskotron-disposable/'
-                     'repo/fedora-21/tflink-taskotron-disposable-fedora-21.repo')
-        self.ssh.install_pkgs('libtaskotron')
-
-        # configure libtaskotron
-        if config.get_config().config_filename:
-            self.ssh.put_file(config.get_config().config_filename, '/etc/taskotron/taskotron.yaml')
-
-        # create needed dirs
-        self.ssh.mkdir(self.taskdir)
-
-        # patch remote libtaskotron if needed (for dev, shouldn't be used in production)
-        if self.arg_data['patch'] is not None:
-            self.ssh.put_file(os.path.abspath(self.arg_data['patch']), '%s/%s' % (
-                self.taskdir, self.arg_data['patch']))
-            self.ssh.install_pkgs('patch')
-            self.ssh.cmd('patch -d /usr/lib/python2.7/site-packages/ -p1 -i %s/%s' % (
-                self.taskdir, self.arg_data['patch']))
-
-        # put files needed for execution (task, input files, etc)
-        self.ssh.put_dir(os.path.dirname(self.arg_data['taskfile']), self.taskdir)
-
-        # need to have default_flow_style false to get valid yaml w/ nested dicts
-        self.ssh.write_file(os.path.join(self.taskdir,
-                                         os.path.basename(self.arg_data['taskfile'])),
-                            yaml.dump(self.task_data, default_flow_style=False))
-
-    def run(self):
-        # execute task
-        task_cmd = ['cd', pipes.quote(self.taskdir), '&&',
-                    'runtask',
-                    '-t', pipes.quote(self.arg_data['type']),
-                    '-i', pipes.quote(self.arg_data['item']),
-                    '--uuid', pipes.quote(self.arg_data['uuid']),
-                    '--local',
-                    pipes.quote(os.path.basename(self.arg_data['taskfile']))]
-
-        self.exitcode = self.ssh.cmd(' '.join(task_cmd))
-
-    def get_output(self):
-        '''copy artifacts from vm'''
-        try:
-            self.ssh.get_dir(self.arg_data['artifactsdir'], self.arg_data['artifactsdir'])
-        except exc.TaskotronRemoteError, e:
-            log.info(e)
-
-
-def get_argparser():
-    parser = argparse.ArgumentParser()
-    parser.add_argument("task", nargs=1, help="task to run")
-    parser.add_argument("-a", "--arch",
-        choices=["i386", "x86_64", "armhfp", "noarch"],
-        action='append',
-        help="architecture specifying the item to be checked. If omitted, "
-             "defaults to noarch.")
-    parser.add_argument("-i", "--item", help="item to be checked")
-    parser.add_argument("-t", "--type",
-                        choices=_ITEM_TYPES,
-                        help="type of --item argument")
-    parser.add_argument("-j", "--jobid", default="-1",
-                        help="optional job identifier used to render log urls")
-    parser.add_argument("-d", "--debug", action="store_true",
-                        help="Enable debug output "
-                             "(set logging level to 'DEBUG')")
-    parser.add_argument("--uuid", default=datetime.datetime.utcnow().strftime("%Y%m%d_%H%M%S_%f"),
-                        help="Unique job identifier for the execution"
-                             "status tracking purposes. If unset, defaults to"
-                             "current datetime in UTC")
-    parser.add_argument("--override", action="append", default=[], metavar='VAR=VALUE',
-                        help="override internal variable values used in runner "
-                             "and the task formula. Value itself is evaluated "
-                             "by eval(). This option can be used multiple times. "
-                             "Example: --override \"workdir='/some/dir/'\"")
-    parser.add_argument("-p", "--patch", help="patch to apply to remote system"
-                        "before execution of task")
-    parser.add_argument("--local", action="store_true",
-                        help="make the task run locally on this very machine (the default "
-                             "behavior for development profile). This also approves any required "
-                             "system-wide changes to be performed (automatic installation of "
-                             "package dependencies, destructive tasks allowed, etc).")
-    parser.add_argument('--libvirt', action='store_true',
-                        help="make the task run remotely in a disposable client spawned using "
-                        "libvirt (the default behavior for production profile).")
-    parser.add_argument("--ssh", metavar='user@machine[:port]',
-                        help="make the task run on a remote machine over ssh")
-    parser.add_argument("--ssh-privkey", metavar="</path/to/private.key>",
-                        help="path to private key for remote connections over ssh")
-    parser.add_argument("--no-destroy", action="store_true",
-                        help="do not destroy disposable client at the end of task execution")
-
-    return parser
-
-
-def check_args(parser, args):
-    """ Check if passed args doesn't have conflicts and have proper format. In case of error, this
-    function prints error message and exits the program.
-
-    :param ArgumentParser parser: parser object used to show error message and exit the program
-    :param dict args: arguments previously returned by argument parser converted to dict
-    """
-
-    if len([arg for arg in [args['local'], args['libvirt'], args['ssh']] if arg]) > 1:
-        parser.error("Args --local, --libvirt and --ssh are mutually exclusive")
-
-    if args['ssh']:
-        if '@' not in args['ssh']:
-            parser.error("SSH connection info not in format 'user@machine' or 'user@machine:port'")
-
-
-def process_args(args):
-    """ Processes raw input args and converts them into specific data types that
-    can be used by tasks. This includes e.g. creating new args based on
-    (item, item_type) pairs, or adjusting selected architecture.
-
-    :param dict args: dictionary of raw arguments
-    :returns: dict of args with appended specific data
-    """
-
-    # process item + type
-    if args['type'] in _ITEM_TYPES:
-        args[args['type']] = args['item']
-
-    # process arch
-    if args['arch'] is None:
-        args['arch'] = ['noarch']
-
-    # using list of tuples instead of dictionary so the args['override'] is
-    # still list at the end of process_args() - for better testability
-    override = []
-    for var in args['override']:
-        name = var.split('=')[0]
-        value = var.split('=')[1]
-        override.append((name, value))
-    args['override'] = override
-
-    args['artifactsdir'] = os.path.join(config.get_config().artifactsdir, args['uuid'])
-
-    return args
-
-
-def main():
-    # Preliminary initialization of logging, so all messages before regular
-    # initialization can be logged to stream.
-    logger.init_prior_config()
-
-    log.info('Execution started at: %s',
-             datetime.datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S UTC'))
-    log.debug('Using libtaskotron %s', libtaskotron.__version__)
-
-    # parse cmdline
-    parser = get_argparser()
-    args = parser.parse_args()
-
-    check_args(parser, vars(args))
-
-    log.debug('Parsed arguments: %s', args)
-
-    arg_data = process_args(vars(args))
-    arg_data['taskfile'] = args.task[0]
-
-    # initialize logging
-    level_stream = logging.DEBUG if args.debug else None
-    logger.init(level_stream=level_stream)
-
-    # create dir for artifacts
-    try:
-        file_utils.makedirs(arg_data['artifactsdir'])
-        log.info("Task artifacts will be saved in: %s", arg_data['artifactsdir'])
-    except OSError:
-        log.error("Can't create artifacts directory %s", arg_data['artifactsdir'])
-        raise
-
-    # parse task formula
-    task_data = taskformula.parse_yaml_from_file(arg_data['taskfile'])
-    if not task_data:
-        raise exc.TaskotronYamlError('Input file should not be empty')
-
-    # run the task, locally or remotely
-    task_runner = Runner(task_data, arg_data)
-    task_runner.run()
-
-    # finalization
-    log.info('Execution finished at: %s. Task artifacts were saved in: %s',
-             datetime.datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S UTC'),
-             task_runner.arg_data['artifactsdir'])
-
-    if task_runner.exitcode == exitcode_directive.FAILURE:
-        log.info("Exiting with exit status %d (this behavior was requested by exitcode directive)"
-                 % task_runner.exitcode)
-        sys.exit(task_runner.exitcode)
-
-    # exitcode with value exitcode_directive.FAILURE *should* be set only by exitcode directive
-    assert task_runner.exitcode in [0, None]
diff --git a/runtask.py b/runtask.py
--- a/runtask.py
+++ b/runtask.py
@@ -4,7 +4,7 @@
 # See the LICENSE file for more details on Licensing
 
 from __future__ import absolute_import
-from libtaskotron import runner
+from libtaskotron import main
 
 if __name__ == '__main__':
-    runner.main()
+    main.main()
diff --git a/testing/functest_runner.py b/testing/functest_main.py
rename from testing/functest_runner.py
rename to testing/functest_main.py
--- a/testing/functest_runner.py
+++ b/testing/functest_main.py
@@ -7,21 +7,22 @@
 import pytest
 import logging
 
-from libtaskotron import runner
+from libtaskotron import main
 from libtaskotron import logger
 
-class TestRunnerMain():
+
+class TestMain():
     '''Test main()'''
 
     def test_debug(self, tmpdir, monkeypatch, capfd):
         '''Test whether --debug cmdline option works'''
         fake_recipe_path = tmpdir.join('nonexistent.yml').strpath
-        fake_cmdline = ['runner.py', '--debug', fake_recipe_path]
+        fake_cmdline = ['runtask', '--debug', fake_recipe_path]
         monkeypatch.setattr(sys, 'argv', fake_cmdline)
 
         with pytest.raises(IOError):
             # an error should be raised, because the yaml file does not exist
-            runner.main()
+            main.main()
 
         # even though the error was raised, the debug level should already be set
         assert logger.stream_handler.level == logging.DEBUG
diff --git a/testing/test_executor.py b/testing/test_executor.py
new file mode 100644
--- /dev/null
+++ b/testing/test_executor.py
@@ -0,0 +1,475 @@
+# -*- coding: utf-8 -*-
+# Copyright 2009-2014, Red Hat, Inc.
+# License: GPL-2.0+ <http://spdx.org/licenses/GPL-2.0+>
+# See the LICENSE file for more details on Licensing
+
+import pytest
+import os
+import sys
+from dingus import Dingus
+
+from libtaskotron import executor
+from libtaskotron import main
+from libtaskotron import config
+from libtaskotron import rpm_utils
+from libtaskotron.directives import exitcode_directive
+from libtaskotron import exceptions as exc
+
+
+class TestExecutorInputVerify():
+    def test_yamlexecutor_valid_input(self):
+        ref_cmd = '-i foo-1.2-3.fc99 -t koji_build -a x86_64 footask.yml'
+        ref_argdata = {'input': {'args': ['koji_build', 'arch']}, 'uuid': ''}
+
+        test_parser = main.get_argparser()
+        test_input_args = vars(test_parser.parse_args(ref_cmd.split()))
+        test_args = main.process_args(test_input_args)
+
+        test_executor = executor.Executor(ref_argdata, test_args)
+
+        test_executor._validate_input()
+
+    def test_yamlexecutor_missing_input(self):
+        ref_cmd = '-i foo-1.2-3.fc99 -t koji_build footask.yml'
+        ref_inputspec = {}
+
+        test_parser = main.get_argparser()
+        test_input_args = vars(test_parser.parse_args(ref_cmd.split()))
+        test_args = main.process_args(test_input_args)
+
+        test_executor = executor.Executor(ref_inputspec, test_args)
+
+        test_executor._validate_input()
+
+    def test_yamlexecutor_fails_empty_input(self):
+        ref_cmd = '-i foo-1.2-3.fc99 -t koji_build footask.yml'
+        ref_inputspec = {'input': None}
+
+        test_parser = main.get_argparser()
+        test_input_args = vars(test_parser.parse_args(ref_cmd.split()))
+        test_args = main.process_args(test_input_args)
+
+        test_executor = executor.Executor(ref_inputspec, test_args)
+
+        with pytest.raises(exc.TaskotronYamlError):
+            test_executor._validate_input()
+
+    def test_yamlexecutor_fails_empty_arg(self):
+        ref_cmd = '-i foo-1.2-3.fc99 -t koji_build footask.yml'
+        ref_inputspec = {'input': {'args': None}}
+
+        test_parser = main.get_argparser()
+        test_input_args = vars(test_parser.parse_args(ref_cmd.split()))
+        test_args = main.process_args(test_input_args)
+
+        test_executor = executor.Executor(ref_inputspec, test_args)
+
+        with pytest.raises(exc.TaskotronYamlError):
+            test_executor._validate_input()
+
+    def test_yamlexecutor_fails_missing_arg(self):
+        ref_cmd = '-i foo-1.2-3.fc99 -t koji_build footask.yml'
+        ref_inputspec = {'input': 'other_item'}
+
+        test_parser = main.get_argparser()
+        test_input_args = vars(test_parser.parse_args(ref_cmd.split()))
+        test_args = main.process_args(test_input_args)
+
+        test_executor = executor.Executor(ref_inputspec, test_args)
+
+        with pytest.raises(exc.TaskotronYamlError):
+            test_executor._validate_input()
+
+    def test_yamlexecutor_fails_undefined_arg(self):
+        ref_cmd = '-i foo-1.2-3.fc99 -t koji_build footask.yml'
+        ref_inputspec = {'input': {'args': 'nonexistent_input_arg'}}
+
+        test_parser = main.get_argparser()
+        test_input_args = vars(test_parser.parse_args(ref_cmd.split()))
+        test_args = main.process_args(test_input_args)
+
+        test_executor = executor.Executor(ref_inputspec, test_args)
+
+        with pytest.raises(exc.TaskotronYamlError):
+            test_executor._validate_input()
+
+    def test_yamlexecutor_fails_input_is_string(self):
+        ref_cmd = '-i foo-1.2-3.fc99 -t koji_build footask.yml'
+        ref_inputspec = {'input': {'args'}}
+
+        test_parser = main.get_argparser()
+        test_input_args = vars(test_parser.parse_args(ref_cmd.split()))
+        test_args = main.process_args(test_input_args)
+
+        test_executor = executor.Executor(ref_inputspec, test_args)
+
+        with pytest.raises(exc.TaskotronYamlError):
+            test_executor._validate_input()
+
+
+class TestExecutorSetup():
+    def test_trivial_creation(self, tmpdir):
+        ref_taskdata = {'preparation': {'koji': 'download nvr'},
+                        'input': {'args': ['koji_build', 'arch']},
+                        'post': {'shell': 'clean'},
+                        'execution': {'python': 'run_rpmlint.py'},
+                        'dependencies': ['rpmlint', 'libtaskbot']}
+        ref_inputdata = {'uuid': ''}
+
+        test_executor = executor.Executor(ref_taskdata, ref_inputdata, tmpdir)
+
+        assert test_executor.task_data == ref_taskdata
+
+
+class TestExecutorDirectiveLoader():
+
+    def setup_method(self, method):
+        self.ref_dir = os.path.abspath('testing/test_directives/')
+        self.ref_taskdata = {}
+        self.ref_inputdata = {'uuid': ''}
+        self.ref_directivename = 'stub'
+
+    def test_executor_load_directive(self, tmpdir):
+        test_executor = executor.Executor(self.ref_taskdata, self.ref_inputdata, tmpdir)
+
+        test_executor._load_directive(self.ref_directivename, self.ref_dir)
+
+        assert '%s_directive' % self.ref_directivename in sys.modules.keys()
+
+    def test_executor_load_directive_indict(self, tmpdir):
+        test_executor = executor.Executor(self.ref_taskdata, self.ref_inputdata, tmpdir)
+
+        test_executor._load_directive(self.ref_directivename, self.ref_dir)
+
+        assert self.ref_directivename in test_executor.directives.keys()
+
+    def test_executor_load_directive_notexist(self, tmpdir):
+        self.ref_directivename = 'noexist'
+
+        test_executor = executor.Executor(self.ref_taskdata, self.ref_inputdata, tmpdir)
+
+        with pytest.raises(exc.TaskotronYamlError):
+            test_executor._load_directive(self.ref_directivename, self.ref_dir)
+
+
+class TestExecutorSingleAction():
+
+    def setup_method(self, method):
+        self.ref_taskdata = {}
+        self.ref_inputdata = {'uuid': ''}
+
+        self.ref_yaml_passed = """
+results:
+  - item: Hello World P
+    outcome: PASSED
+"""
+        self.ref_yaml_failed = """
+results:
+  - item: Hello World P
+    outcome: FAILED
+"""
+
+    def test_executor_extract_directive_name(self):
+        ref_directivename = 'dummy'
+        ref_action = {'name': 'test action',
+                      ref_directivename: {'result': 'pass',
+                                          'msg': 'dummy message'}}
+
+        test_executor = executor.Executor(self.ref_taskdata, self.ref_inputdata)
+
+        test_directivename = test_executor._extract_directive_from_action(
+            ref_action)
+
+        assert test_directivename == ref_directivename
+
+    def test_executor_single_dummy_action_pass(self):
+        ref_action = {'dummy': {'result': 'pass'}}
+        ref_action.update({'name': 'Dummy Action'})
+
+        test_executor = executor.Executor(self.ref_taskdata, self.ref_inputdata)
+
+        test_executor._do_single_action(ref_action)
+
+    def test_executor_single_dummy_action_fail(self):
+        ref_action = {'name': 'Dummy Action',
+                      'dummy': {'result': 'fail'}}
+
+        test_executor = executor.Executor(self.ref_taskdata, self.ref_inputdata)
+
+        with pytest.raises(exc.TaskotronDirectiveError):
+            test_executor._do_single_action(ref_action)
+
+    def test_executor_do_single_dummy_action_pass_export(self):
+        ref_exportname = 'dummy_output'
+        ref_output = 'this is example output'
+        ref_action = {'name': 'Dummy Action',
+                      'dummy': {'result': 'pass', 'msg': ref_output},
+                      'export': '%s' % ref_exportname}
+
+        test_executor = executor.Executor(self.ref_taskdata, self.ref_inputdata)
+
+        test_executor._do_single_action(ref_action)
+
+        assert test_executor.working_data[ref_exportname] == ref_output
+
+    def test_executor_do_single_dummy_action_pass_export_rendered_msg(self):
+        ref_exportname = 'dummy_output'
+        ref_message = 'This is a variable message'
+        ref_messagename = 'variable_messsage'
+        ref_action = {'name': 'Dummy Action',
+                      'dummy': {'result': 'pass',
+                                'msg': '${%s}' % ref_messagename},
+                      'export': '%s' % ref_exportname}
+
+        test_executor = executor.Executor(self.ref_taskdata, self.ref_inputdata)
+        test_executor.working_data[ref_messagename] = ref_message
+
+        test_executor._do_single_action(ref_action)
+
+        assert test_executor.working_data[ref_exportname] == ref_message
+
+    def test_executor_do_single_dummy_action_exitcode_None(self):
+        ref_action = {'name': 'Return exitcode', 'exitcode': {'result_last': self.ref_yaml_passed}}
+
+        test_executor = executor.Executor(self.ref_taskdata, self.ref_inputdata)
+
+        test_executor._do_single_action(ref_action)
+
+        assert test_executor.exitcode == exitcode_directive.SUCCESS
+
+    def test_executor_do_single_dummy_action_exitcode_success(self):
+        ref_action = {'name': 'Return exitcode', 'exitcode': {'result_last': self.ref_yaml_passed}}
+
+        test_executor = executor.Executor(self.ref_taskdata, self.ref_inputdata)
+
+        test_executor._do_single_action(ref_action)
+
+        assert test_executor.exitcode == exitcode_directive.SUCCESS
+
+    def test_executor_do_single_dummy_action_exitcode_failure(self):
+        ref_action = {'name': 'Return exitcode', 'exitcode': {'result_last': self.ref_yaml_failed}}
+
+        test_executor = executor.Executor(self.ref_taskdata, self.ref_inputdata)
+
+        test_executor._do_single_action(ref_action)
+
+        assert test_executor.exitcode == exitcode_directive.FAILURE
+
+    def test_executor_do_single_dummy_action_exitcode_failure_overwrite_success(self):
+        ref_action = {'name': 'Return exitcode', 'exitcode': {'result_last': self.ref_yaml_failed}}
+
+        test_executor = executor.Executor(self.ref_taskdata, self.ref_inputdata)
+        test_executor.exitcode = exitcode_directive.SUCCESS
+        test_executor._do_single_action(ref_action)
+
+        assert test_executor.exitcode == exitcode_directive.FAILURE
+
+    def test_executor_do_single_dummy_action_exitcode_success_not_overwrite_failure(self):
+        ref_action = {'name': 'Return exitcode', 'exitcode': {'result_last': self.ref_yaml_passed}}
+
+        test_executor = executor.Executor(self.ref_taskdata, self.ref_inputdata)
+        test_executor.exitcode = exitcode_directive.FAILURE
+        test_executor._do_single_action(ref_action)
+
+        assert test_executor.exitcode == exitcode_directive.FAILURE
+
+
+class TestExecutorDoActions():
+
+    def setup_method(self, method):
+        self.ref_taskdata = {}
+        self.ref_inputdata = {'uuid': ''}
+
+    def test_executor_do_single_dummy_pass(self):
+        ref_action = {'name': 'Dummy Action',
+                      'dummy': {'result': 'pass'}}
+        self.ref_taskdata = {'actions': [ref_action]}
+
+        test_executor = executor.Executor(self.ref_taskdata, self.ref_inputdata)
+
+        test_executor._do_actions()
+
+    def test_executor_do_single_dummy_fail(self):
+        ref_action = {'name': 'Dummy Action',
+                      'dummy': {'result': 'fail'}}
+        self.ref_taskdata = {'actions': [ref_action]}
+
+        test_executor = executor.Executor(self.ref_taskdata, self.ref_inputdata)
+
+        with pytest.raises(exc.TaskotronDirectiveError):
+            test_executor._do_actions()
+
+    def test_executor_do_multiple_dummy_fail(self):
+        ref_action = {'name': 'Dummy Action',
+                      'dummy': {'result': 'fail'}}
+        self.ref_taskdata = {'actions': [ref_action, ref_action]}
+
+        test_executor = executor.Executor(self.ref_taskdata, self.ref_inputdata)
+
+        with pytest.raises(exc.TaskotronDirectiveError):
+            test_executor._do_actions()
+
+    def test_executor_do_multiple_dummy_pass_export_rendered_msg(self):
+        ref_exportname = 'dummy_output'
+        ref_message = 'This is a variable message'
+        ref_messagename = 'variable_messsage'
+        ref_action = {'name': 'Dummy Action',
+                      'dummy': {'result': 'pass',
+                                'msg': '${%s}' % ref_messagename},
+                      'export': '%s' % ref_exportname}
+
+        self.ref_taskdata = {'actions': [ref_action, ref_action]}
+
+        test_executor = executor.Executor(self.ref_taskdata, self.ref_inputdata)
+        test_executor.working_data[ref_messagename] = ref_message
+
+        test_executor._do_actions()
+
+        assert test_executor.working_data[ref_exportname] == ref_message
+
+    def test_executor_fails_missing_action(self):
+        self.ref_taskdata = {'actions': None}
+
+        test_executor = executor.Executor(self.ref_taskdata, self.ref_inputdata)
+
+        with pytest.raises(exc.TaskotronYamlError):
+            test_executor._do_actions()
+
+    def test_executor_fails_missing_task(self):
+        self.ref_taskdata = {}
+
+        test_executor = executor.Executor(self.ref_taskdata, self.ref_inputdata)
+
+        with pytest.raises(exc.TaskotronYamlError):
+            test_executor._do_actions()
+
+
+class TestExecutorRenderAction():
+
+    def setup_method(self, method):
+        self.ref_taskdata = {}
+        self.ref_inputdata = {'uuid': ''}
+
+    def test_executor_render_single_variable(self):
+        ref_message = 'This is a variable message'
+        ref_messagename = 'variable_messsage'
+        ref_action = {'name': 'test dummy action', 'dummy':
+                      {'result': 'pass', 'msg': '${%s}' % ref_messagename}}
+
+        test_executor = executor.Executor(self.ref_taskdata, self.ref_inputdata)
+        test_executor.working_data[ref_messagename] = ref_message
+
+        test_rendered_action = test_executor._render_action(ref_action)
+
+        assert test_rendered_action['dummy']['msg'] == ref_message
+
+    def test_executor_render_list_variable(self):
+        ref_var = ['foo', 'bar']
+        ref_varname = 'testvar'
+        ref_action = {'name': 'test dummy action', 'dummy':
+                      {'result': 'pass', 'msg': '${%s}' % ref_varname}}
+
+        test_executor = executor.Executor(self.ref_taskdata, self.ref_inputdata)
+        test_executor.working_data[ref_varname] = ref_var
+
+        test_rendered_action = test_executor._render_action(ref_action)
+
+        assert test_rendered_action['dummy']['msg'] == ref_var
+
+    def test_executor_render_multi_variable(self):
+        ref_var1 = 'foo'
+        ref_var1name = 'testvar1'
+        ref_var2 = 'bar'
+        ref_var2name = 'testvar2'
+        ref_action = {'name': 'test dummy action', 'dummy':
+                      {'result': 'pass', 'msg': '--foo ${%s} --bar ${%s}' %
+                       (ref_var1name, ref_var2name)}}
+
+        test_executor = executor.Executor(self.ref_taskdata, self.ref_inputdata)
+        test_executor.working_data[ref_var1name] = ref_var1
+        test_executor.working_data[ref_var2name] = ref_var2
+
+        test_rendered_action = test_executor._render_action(ref_action)
+
+        assert (test_rendered_action['dummy']['msg'] == '--foo %s --bar %s' %
+                (ref_var1, ref_var2))
+
+
+class TestExecutorCheckOverride():
+
+    def test_override_existing(self):
+        ref_input = {'arch': ['x86_64'],
+                     'type': 'koji_build',
+                     'item': 'foo-1.2-3.fc99',
+                     'uuid': '20150220_134110_955656',
+                     'ssh': None,
+                     'local': False,
+                     'override': ["workdir='/some/dir/'"]}
+
+        ref_inputdata = main.process_args(ref_input)
+
+        test_executor = executor.Executor(Dingus(), ref_inputdata)
+        test_executor._do_actions = lambda: None
+        test_executor._run()
+
+        assert test_executor.arg_data['workdir'] == "/some/dir/"
+
+    def test_override_nonexisting(self):
+        ref_input = {'arch': ['x86_64'],
+                     'type': 'koji_build',
+                     'item': 'foo-1.2-3.fc99',
+                     'uuid': '20150220_134110_955656',
+                     'ssh': None,
+                     'local': False,
+                     'override': ["friendship='is magic'"]}
+
+        ref_inputdata = main.process_args(ref_input)
+
+        test_executor = executor.Executor(Dingus(), ref_inputdata)
+        test_executor._do_actions = lambda: None
+        test_executor._run()
+
+        assert test_executor.arg_data['friendship'] == "is magic"
+
+
+@pytest.mark.usefixtures('setup')
+class TestExecutorPrepareTask():
+
+    @pytest.fixture
+    def setup(self, monkeypatch):
+        self.install_stub = Dingus()
+        self.is_installed_stub = Dingus()
+        self.stub_config = Dingus()
+        self.stub_get_config = Dingus()
+
+        self.stub_get_config.return_value = self.stub_config
+        monkeypatch.setattr(rpm_utils, 'install', self.install_stub)
+        monkeypatch.setattr(rpm_utils, 'is_installed', self.is_installed_stub)
+        monkeypatch.setattr(config, 'get_config', self.stub_get_config)
+
+        self.test_executor = executor.Executor({}, {'local': False, 'ssh': None, 'uuid': ''})
+
+    def test_devel_profile_installed(self):
+        self.is_installed_stub.return_value = True
+        self.stub_config.profile = 'devel'
+
+        self.test_executor._prepare_task()
+
+        assert len(self.install_stub.calls()) == 0 and len(self.is_installed_stub.calls()) == 1
+
+    def test_devel_profile_not_installed(self):
+        self.is_installed_stub.return_value = False
+        self.stub_config.profile = 'devel'
+
+        with pytest.raises(exc.TaskotronError):
+            self.test_executor._prepare_task()
+
+        assert len(self.install_stub.calls()) == 0 and len(self.is_installed_stub.calls()) == 1
+
+    def test_prod_profile(self):
+        self.is_installed_stub.return_value = True
+        self.stub_config.profile = 'production'
+
+        self.test_executor._prepare_task()
+
+        assert len(self.install_stub.calls()) == 1 and len(self.is_installed_stub.calls()) == 0
diff --git a/testing/test_exitcode_directive.py b/testing/test_exitcode_directive.py
--- a/testing/test_exitcode_directive.py
+++ b/testing/test_exitcode_directive.py
@@ -5,8 +5,9 @@
 
 from libtaskotron.directives import exitcode_directive
 from libtaskotron.exceptions import TaskotronDirectiveError, TaskotronValueError
-from libtaskotron import runner
-from libtaskotron import remote_exec
+from libtaskotron import overlord
+from libtaskotron import taskformula
+from libtaskotron import file_utils
 from dingus import Dingus
 import pytest
 
@@ -73,16 +74,20 @@
 class TestRunnerExitcode(object):
 
     @pytest.fixture
-    def setup(self):
-        task_data = {}
+    def setup(self, monkeypatch):
         arg_data = {'local': None,
                     'libvirt': None,
                     'ssh': None,
                     'ssh_privkey': None,
+                    'uuid': '',
+                    'taskfile': '',
                     'no_destroy': None}
         arg_data['artifactsdir'] = '/dir'
 
-        self.test_runner = runner.Runner(task_data, arg_data)
+        monkeypatch.setattr(taskformula, 'parse_yaml_from_file', Dingus())
+        monkeypatch.setattr(file_utils, 'makedirs', Dingus())
+        self.test_runner = overlord.Overlord(arg_data)
+
         self.stub_runner = Dingus()
         self.stub_runner.exitcode = exitcode_directive.FAILURE
 
@@ -92,16 +97,13 @@
     def test_local_propagate(self, monkeypatch):
         stub_local = Dingus()
         stub_local.return_value = self.stub_runner
-        monkeypatch.setattr(runner, 'LocalRunner', stub_local)
-        self.test_runner._run_locally()
+        monkeypatch.setattr(self.test_runner, '_get_runner', stub_local)
+        self.test_runner.start()
         assert self.test_runner.exitcode == exitcode_directive.FAILURE
 
     def test_remote_propagate(self, monkeypatch):
         stub_remote = Dingus()
         stub_remote.return_value = self.stub_runner
-        monkeypatch.setattr(runner, 'RemoteRunner', stub_remote)
-        monkeypatch.setattr(remote_exec, 'RemoteExec', Dingus())
-        # skip the disposable vm creation
-        self.test_runner.direct_ssh = True
-        self.test_runner._run_remotely()
+        monkeypatch.setattr(self.test_runner, '_get_runner', stub_remote)
+        self.test_runner.start()
         assert self.test_runner.exitcode == exitcode_directive.FAILURE
diff --git a/testing/test_main.py b/testing/test_main.py
new file mode 100644
--- /dev/null
+++ b/testing/test_main.py
@@ -0,0 +1,177 @@
+# -*- coding: utf-8 -*-
+# Copyright 2009-2014, Red Hat, Inc.
+# License: GPL-2.0+ <http://spdx.org/licenses/GPL-2.0+>
+# See the LICENSE file for more details on Licensing
+
+import pytest
+import os
+import copy
+from dingus import Dingus
+
+from libtaskotron import main
+from libtaskotron import config
+
+
+@pytest.mark.usefixtures('setup')
+class TestProcessArgs():
+
+    @pytest.fixture
+    def setup(self, monkeypatch):
+        self.ref_uuid = '20150930_153933_359680'
+        self.ref_artifactsdir = '/dir/'
+        self.stub_config = Dingus()
+        self.stub_get_config = Dingus()
+
+        self.stub_get_config.return_value = self.stub_config
+        monkeypatch.setattr(config, 'get_config', self.stub_get_config)
+        self.stub_config.artifactsdir = self.ref_artifactsdir
+
+    def test_process_args_koji_build(self):
+        ref_input = {'arch': ['x86_64'],
+                     'item': 'foo-1.2-3.fc99',
+                     'type': 'koji_build',
+                     'task': 'sometask.yml',
+                     'ssh': None,
+                     'uuid': '',
+                     'override': []}
+
+        ref_args = copy.deepcopy(ref_input)
+        ref_args['koji_build'] = 'foo-1.2-3.fc99'
+        ref_args['artifactsdir'] = self.ref_artifactsdir+self.ref_uuid
+
+        test_args = main.process_args(ref_input)
+        ref_args['artifactsdir'] = os.path.join(config.get_config().artifactsdir, ref_args['uuid'])
+        assert test_args == ref_args
+
+    def test_process_args_bodhi_id(self):
+        ref_input = {'arch': ['x86_64'],
+                     'item': 'foo-1.2-3.fc99',
+                     'type': 'bodhi_id',
+                     'task': 'sometask.yml',
+                     'ssh': None,
+                     'uuid': '',
+                     'override': []}
+
+        ref_args = copy.deepcopy(ref_input)
+        ref_args['bodhi_id'] = 'foo-1.2-3.fc99'
+        ref_args['artifactsdir'] = self.ref_artifactsdir+self.ref_uuid
+
+        test_args = main.process_args(ref_input)
+        ref_args['artifactsdir'] = os.path.join(config.get_config().artifactsdir, ref_args['uuid'])
+        assert test_args == ref_args
+
+    def test_process_args_koji_tag(self):
+        ref_input = {'arch': ['x86_64'],
+                     'item': 'dist-fc99-updates',
+                     'type': 'koji_tag',
+                     'task': 'sometask.yml',
+                     'ssh': None,
+                     'uuid': '',
+                     'override': []}
+
+        ref_args = copy.deepcopy(ref_input)
+        ref_args['koji_tag'] = 'dist-fc99-updates'
+        ref_args['artifactsdir'] = self.ref_artifactsdir+self.ref_uuid
+
+        test_args = main.process_args(ref_input)
+        ref_args['artifactsdir'] = os.path.join(config.get_config().artifactsdir, ref_args['uuid'])
+        assert test_args == ref_args
+
+    def test_process_args_compose(self):
+        ref_input = {'arch': [''],
+                     'item': 'rawhide',
+                     'type': 'compose',
+                     'task': 'sometask.yml',
+                     'ssh': None,
+                     'uuid': '',
+                     'override': []}
+
+        ref_args = copy.deepcopy(ref_input)
+        ref_args['compose'] = 'rawhide'
+        ref_args['artifactsdir'] = self.ref_artifactsdir+self.ref_uuid
+
+        test_args = main.process_args(ref_input)
+        ref_args['artifactsdir'] = os.path.join(config.get_config().artifactsdir, ref_args['uuid'])
+        assert test_args == ref_args
+
+    def test_process_args_koji_tag_multiple_arches(self):
+        ref_input = {'arch': ['x86_64', 'i386', 'noarch'],
+                     'item': 'dist-fc99-updates',
+                     'type': 'koji_tag',
+                     'task': 'sometask.yml',
+                     'ssh': None,
+                     'uuid': '',
+                     'override': []}
+
+        ref_args = copy.deepcopy(ref_input)
+        ref_args['koji_tag'] = 'dist-fc99-updates'
+        ref_args['artifactsdir'] = self.ref_artifactsdir+self.ref_uuid
+
+        test_args = main.process_args(ref_input)
+        ref_args['artifactsdir'] = os.path.join(config.get_config().artifactsdir, ref_args['uuid'])
+        assert test_args == ref_args
+
+    def test_process_args_no_arch(self):
+        ref_input = {'type': 'invalid',
+                     'arch': None,
+                     'ssh': None,
+                     'uuid': '',
+                     'override': []}
+
+        ref_args = copy.deepcopy(ref_input)
+        ref_args['arch'] = ['noarch']
+        ref_args['artifactsdir'] = self.ref_artifactsdir+self.ref_uuid
+
+        test_args = main.process_args(ref_input)
+        ref_args['artifactsdir'] = os.path.join(config.get_config().artifactsdir, ref_args['uuid'])
+        assert test_args == ref_args
+
+
+@pytest.mark.usefixtures('setup')
+class TestCheckArgs():
+
+    @pytest.fixture
+    def setup(self):
+        self.empty_args = {'local': None, 'libvirt': None, 'ssh': None}
+
+    def test_runmode_exclusive(self):
+        '''allow only single runmode selection'''
+        ref_inputdata_bad = [{'local': True, 'ssh': 'user@machine'},
+                             {'local': True, 'libvirt': True},
+                             {'libvirt': True, 'ssh': 'user@machine'},
+                             {'local': True, 'ssh': 'user@machine', 'libvirt': True}]
+        ref_inputdata_good = [{'local': True},
+                              {'ssh': 'user@machine'},
+                              {'libvirt': True},
+                              {}]
+
+        for ref_inputdata in ref_inputdata_bad:
+            stub_error = Dingus()
+            stub_parser = Dingus()
+            stub_parser.error = stub_error
+            args = self.empty_args.copy()
+            args.update(ref_inputdata)
+            main.check_args(stub_parser, args)
+            assert len(stub_error.calls()) == 1
+
+        for ref_inputdata in ref_inputdata_good:
+            stub_error = Dingus()
+            stub_parser = Dingus()
+            stub_parser.error = stub_error
+            args = self.empty_args.copy()
+            args.update(ref_inputdata)
+            main.check_args(stub_parser, args)
+            assert len(stub_error.calls()) == 0
+
+    def test_ssh_bad_usermachine(self):
+        '''fail on invalid user@machine[:port] specifier'''
+        ref_inputdata = {'ssh': 'user#machine'}
+
+        stub_error = Dingus()
+        stub_parser = Dingus()
+        stub_parser.error = stub_error
+        args = self.empty_args
+        args.update(ref_inputdata)
+        main.check_args(stub_parser, args)
+
+        assert len(stub_error.calls()) == 1
diff --git a/testing/test_overlord.py b/testing/test_overlord.py
new file mode 100644
--- /dev/null
+++ b/testing/test_overlord.py
@@ -0,0 +1,126 @@
+# -*- coding: utf-8 -*-
+# Copyright 2009-2014, Red Hat, Inc.
+# License: GPL-2.0+ <http://spdx.org/licenses/GPL-2.0+>
+# See the LICENSE file for more details on Licensing
+
+import pytest
+from dingus import Dingus
+
+from libtaskotron import executor
+from libtaskotron import config
+from libtaskotron import taskformula
+from libtaskotron import file_utils
+from libtaskotron import minion
+from libtaskotron import overlord
+
+
+@pytest.mark.usefixtures('setup')
+class TestOverlord():
+
+    @pytest.fixture
+    def setup(self, monkeypatch):
+        self.stub_local = Dingus()
+        self.stub_remote = Dingus()
+        self.stub_remote_persistent = Dingus()
+        self.uuid_dir = '/something/totally/random'
+        monkeypatch.setattr(taskformula, 'parse_yaml_from_file', Dingus())
+        monkeypatch.setattr(file_utils, 'makedirs', Dingus())
+
+        monkeypatch.setattr(executor, 'Executor', self.stub_local)
+        monkeypatch.setattr(minion, 'DisposableMinion', self.stub_remote)
+        monkeypatch.setattr(minion, 'PersistentMinion', self.stub_remote_persistent)
+
+        self.stub_get_config = Dingus()
+        self.stub_config_local = Dingus()
+        self.stub_config_local.runtask_mode = config.RuntaskModeName.LOCAL
+        self.stub_config_remote = Dingus()
+        self.stub_config_remote.runtask_mode = config.RuntaskModeName.LIBVIRT
+
+        self.empty_args = {'local': None, 'libvirt': None, 'ssh': None, 'ssh_privkey': None,
+                           'uuid': '', 'taskfile': '', 'artifactsdir': '/dir'}
+        self.fake_taskdata = {'environment': {'rpm': ['foo', 'bar']}}
+
+    def test_local_no_cli(self, monkeypatch):
+        '''run locally with local runmode in config and no override from cli'''
+        ref_inputdata = self.empty_args.copy()
+        self.stub_get_config.return_value = self.stub_config_local
+        monkeypatch.setattr(config, 'get_config', self.stub_get_config)
+
+        test_runner = overlord.Overlord(ref_inputdata)
+
+        test_runner.start()
+        assert len(self.stub_local.calls()) == 1
+        assert len(self.stub_remote.calls()) == 0
+        assert len(self.stub_remote_persistent.calls()) == 0
+
+    def test_remote_no_cli(self, monkeypatch):
+        '''run remotely with remote runmode in config and no override from cli'''
+        ref_inputdata = self.empty_args.copy()
+        self.stub_get_config.return_value = self.stub_config_remote
+        monkeypatch.setattr(config, 'get_config', self.stub_get_config)
+
+        test_runner = overlord.Overlord(ref_inputdata)
+
+        test_runner.start()
+        assert len(self.stub_local.calls()) == 0
+        assert len(self.stub_remote.calls()) == 1
+        assert len(self.stub_remote_persistent.calls()) == 0
+
+    def test_remote_override_local(self, monkeypatch):
+        '''run locally with remote runmode in config and local override from cli'''
+        ref_inputdata = self.empty_args.copy()
+        ref_inputdata.update({'local': True})
+        self.stub_get_config.return_value = self.stub_config_remote
+        monkeypatch.setattr(config, 'get_config', self.stub_get_config)
+
+        test_runner = overlord.Overlord(ref_inputdata)
+
+        test_runner.start()
+        assert len(self.stub_local.calls()) == 1
+        assert len(self.stub_remote.calls()) == 0
+        assert len(self.stub_remote_persistent.calls()) == 0
+
+    def test_local_override_ssh(self, monkeypatch):
+        '''run remotely with local runmode in config and ssh override from cli'''
+        ref_inputdata = self.empty_args.copy()
+        ref_inputdata.update({'ssh': 'david@HAL9000'})
+        self.stub_get_config.return_value = self.stub_config_local
+        monkeypatch.setattr(config, 'get_config', self.stub_get_config)
+
+        test_runner = overlord.Overlord(ref_inputdata)
+
+        test_runner.start()
+        assert len(self.stub_local.calls()) == 0
+        assert len(self.stub_remote.calls()) == 0
+        assert len(self.stub_remote_persistent.calls()) == 1
+
+    def test_local_override_libvirt(self, monkeypatch):
+        '''run remotely with local runmode in config and libvirt override from cli'''
+        ref_inputdata = self.empty_args.copy()
+        ref_inputdata.update({'libvirt': True})
+        self.stub_get_config.return_value = self.stub_config_local
+        monkeypatch.setattr(config, 'get_config', self.stub_get_config)
+
+        test_runner = overlord.Overlord(ref_inputdata)
+
+        test_runner.start()
+        assert len(self.stub_local.calls()) == 0
+        assert len(self.stub_remote.calls()) == 1
+        assert len(self.stub_remote_persistent.calls()) == 0
+
+    def test_executor_used(self, monkeypatch):
+        monkeypatch.setattr(taskformula, 'parse_yaml_from_file', Dingus())
+        monkeypatch.setattr(file_utils, 'makedirs', Dingus())
+
+        ref_inputdata = {'local': True, 'ssh': None, 'ssh_privkey': None, 'uuid': '',
+                         'taskfile': '', 'artifactsdir': '/dir'}
+
+        stub_local_runner = Dingus()
+        monkeypatch.setattr(executor, 'Executor', stub_local_runner)
+
+        test_runner = overlord.Overlord(ref_inputdata)
+
+        test_runner.start()
+
+        # check that LocalRunner was instantiated
+        assert len(stub_local_runner.calls()) == 1
diff --git a/testing/test_remote_exec.py b/testing/test_remote_exec.py
--- a/testing/test_remote_exec.py
+++ b/testing/test_remote_exec.py
@@ -146,7 +146,7 @@
         '''Run this before every test invocation'''
         self.ref_host_ip = '192.168.122.22'
         paramiko.SSHClient = StubSSHClient
-        self.remote = remote_exec.RemoteExec(self.ref_host_ip, 22, 'root', 'keyfilename')
+        self.remote = remote_exec.ParamikoWrapper(self.ref_host_ip, 22, 'root', 'keyfilename')
         self.remote.connect()
 
     def teardown_method(self, method):
@@ -190,17 +190,18 @@
         assert self.remote.put_file(STUB_FILE, STUB_FILE, overwrite=False) is None
 
     def test_connect_nonexisting_host1(self):
-        remote = remote_exec.RemoteExec(STUB_NONEXISTING_HOST1, 22, 'root', 'keyfilename')
+        remote = remote_exec.ParamikoWrapper(STUB_NONEXISTING_HOST1, 22, 'root', 'keyfilename')
         with pytest.raises(TaskotronRemoteError):
             remote.connect()
 
     def test_connect_nonexisting_host2(self):
-        remote = remote_exec.RemoteExec(STUB_NONEXISTING_HOST2, 22, 'root', 'keyfilename')
+        remote = remote_exec.ParamikoWrapper(STUB_NONEXISTING_HOST2, 22, 'root', 'keyfilename')
         with pytest.raises(TaskotronRemoteError):
             remote.connect()
 
     def test_connect_nonexisting_user(self):
-        remote = remote_exec.RemoteExec(self.ref_host_ip, 22, STUB_NONEXISTING_USER, 'keyfilename')
+        remote = remote_exec.ParamikoWrapper(self.ref_host_ip, 22, STUB_NONEXISTING_USER,
+                                             'keyfilename')
         with pytest.raises(TaskotronRemoteError):
             remote.connect()
 
diff --git a/testing/test_runner.py b/testing/test_runner.py
deleted file mode 100644
--- a/testing/test_runner.py
+++ /dev/null
@@ -1,754 +0,0 @@
-# -*- coding: utf-8 -*-
-# Copyright 2009-2014, Red Hat, Inc.
-# License: GPL-2.0+ <http://spdx.org/licenses/GPL-2.0+>
-# See the LICENSE file for more details on Licensing
-
-import pytest
-import os
-import sys
-import copy
-from dingus import Dingus
-
-from libtaskotron import runner
-from libtaskotron import config
-from libtaskotron import rpm_utils
-from libtaskotron.directives import exitcode_directive
-from libtaskotron import exceptions as exc
-
-
-class TestLocalRunnerInputVerify():
-    def test_yamlrunner_valid_input(self):
-        ref_cmd = '-i foo-1.2-3.fc99 -t koji_build -a x86_64 footask.yml'
-        ref_argdata = {'input': {'args': ['koji_build', 'arch']}}
-
-        test_parser = runner.get_argparser()
-        test_input_args = vars(test_parser.parse_args(ref_cmd.split()))
-        test_args = runner.process_args(test_input_args)
-
-        test_runner = runner.LocalRunner(ref_argdata, test_args)
-
-        test_runner._validate_input()
-
-    def test_yamlrunner_missing_input(self):
-        ref_cmd = '-i foo-1.2-3.fc99 -t koji_build footask.yml'
-        ref_inputspec = {}
-
-        test_parser = runner.get_argparser()
-        test_input_args = vars(test_parser.parse_args(ref_cmd.split()))
-        test_args = runner.process_args(test_input_args)
-
-        test_runner = runner.LocalRunner(ref_inputspec, test_args)
-
-        test_runner._validate_input()
-
-    def test_yamlrunner_fails_empty_input(self):
-        ref_cmd = '-i foo-1.2-3.fc99 -t koji_build footask.yml'
-        ref_inputspec = {'input': None}
-
-        test_parser = runner.get_argparser()
-        test_input_args = vars(test_parser.parse_args(ref_cmd.split()))
-        test_args = runner.process_args(test_input_args)
-
-        test_runner = runner.LocalRunner(ref_inputspec, test_args)
-
-        with pytest.raises(exc.TaskotronYamlError):
-            test_runner._validate_input()
-
-    def test_yamlrunner_fails_empty_arg(self):
-        ref_cmd = '-i foo-1.2-3.fc99 -t koji_build footask.yml'
-        ref_inputspec = {'input': {'args': None}}
-
-        test_parser = runner.get_argparser()
-        test_input_args = vars(test_parser.parse_args(ref_cmd.split()))
-        test_args = runner.process_args(test_input_args)
-
-        test_runner = runner.LocalRunner(ref_inputspec, test_args)
-
-        with pytest.raises(exc.TaskotronYamlError):
-            test_runner._validate_input()
-
-    def test_yamlrunner_fails_missing_arg(self):
-        ref_cmd = '-i foo-1.2-3.fc99 -t koji_build footask.yml'
-        ref_inputspec = {'input': 'other_item'}
-
-        test_parser = runner.get_argparser()
-        test_input_args = vars(test_parser.parse_args(ref_cmd.split()))
-        test_args = runner.process_args(test_input_args)
-
-        test_runner = runner.LocalRunner(ref_inputspec, test_args)
-
-        with pytest.raises(exc.TaskotronYamlError):
-            test_runner._validate_input()
-
-    def test_yamlrunner_fails_undefined_arg(self):
-        ref_cmd = '-i foo-1.2-3.fc99 -t koji_build footask.yml'
-        ref_inputspec = {'input': {'args': 'nonexistent_input_arg'}}
-
-        test_parser = runner.get_argparser()
-        test_input_args = vars(test_parser.parse_args(ref_cmd.split()))
-        test_args = runner.process_args(test_input_args)
-
-        test_runner = runner.LocalRunner(ref_inputspec, test_args)
-
-        with pytest.raises(exc.TaskotronYamlError):
-            test_runner._validate_input()
-
-    def test_yamlrunner_fails_input_is_string(self):
-        ref_cmd = '-i foo-1.2-3.fc99 -t koji_build footask.yml'
-        ref_inputspec = {'input': {'args'}}
-
-        test_parser = runner.get_argparser()
-        test_input_args = vars(test_parser.parse_args(ref_cmd.split()))
-        test_args = runner.process_args(test_input_args)
-
-        test_runner = runner.LocalRunner(ref_inputspec, test_args)
-
-        with pytest.raises(exc.TaskotronYamlError):
-            test_runner._validate_input()
-
-
-class TestLocalRunnerSetup():
-    def test_trivial_creation(self, tmpdir):
-        ref_taskdata = {'preparation': {'koji': 'download nvr'},
-                    'input': {'args': ['koji_build','arch']},
-                    'post': {'shell': 'clean'},
-                    'execution': {'python': 'run_rpmlint.py'},
-                    'dependencies': ['rpmlint', 'libtaskbot']}
-        ref_inputdata = {}
-
-        test_runner = runner.LocalRunner(ref_taskdata, ref_inputdata, tmpdir)
-
-        assert test_runner.task_data == ref_taskdata
-
-
-class TestLocalRunnerDirectiveLoader():
-
-    def setup_method(self, method):
-        self.ref_dir = os.path.abspath('testing/test_directives/')
-        self.ref_taskdata = {}
-        self.ref_inputdata = {}
-        self.ref_directivename = 'stub'
-
-    def test_runner_load_directive(self, tmpdir):
-        test_runner = runner.LocalRunner(self.ref_taskdata, self.ref_inputdata,
-                                    tmpdir)
-
-        test_runner._load_directive(self.ref_directivename, self.ref_dir)
-
-        assert '%s_directive' % self.ref_directivename in sys.modules.keys()
-
-    def test_runner_load_directive_indict(self, tmpdir):
-        test_runner = runner.LocalRunner(self.ref_taskdata, self.ref_inputdata,
-                                    tmpdir)
-
-        test_runner._load_directive(self.ref_directivename, self.ref_dir)
-
-        assert self.ref_directivename in test_runner.directives.keys()
-
-    def test_runner_load_directive_notexist(self, tmpdir):
-        self.ref_directivename = 'noexist'
-
-        test_runner = runner.LocalRunner(self.ref_taskdata, self.ref_inputdata,
-                                    tmpdir)
-
-        with pytest.raises(exc.TaskotronYamlError):
-            test_runner._load_directive(self.ref_directivename, self.ref_dir)
-
-
-class TestLocalRunnerSingleAction():
-
-    def setup_method(self, method):
-        self.ref_taskdata = {}
-        self.ref_inputdata = {}
-
-        self.ref_yaml_passed = """
-results:
-  - item: Hello World P
-    outcome: PASSED
-"""
-        self.ref_yaml_failed = """
-results:
-  - item: Hello World P
-    outcome: FAILED
-"""
-
-
-    def test_runner_extract_directive_name(self):
-        ref_directivename = 'dummy'
-        ref_action = {'name': 'test action',
-                      ref_directivename: {'result': 'pass',
-                                          'msg': 'dummy message'}}
-
-        test_runner = runner.LocalRunner(self.ref_taskdata, self.ref_inputdata)
-
-        test_directivename = test_runner._extract_directive_from_action(
-            ref_action)
-
-        assert test_directivename == ref_directivename
-
-    def test_runner_single_dummy_action_pass(self):
-        ref_action = {'dummy': {'result': 'pass'}}
-        ref_action.update({'name': 'Dummy Action'})
-
-        test_runner = runner.LocalRunner(self.ref_taskdata, self.ref_inputdata)
-
-        test_runner.do_single_action(ref_action)
-
-
-    def test_runner_single_dummy_action_fail(self):
-        ref_action = {'name': 'Dummy Action',
-                      'dummy': {'result': 'fail'}}
-
-        test_runner = runner.LocalRunner(self.ref_taskdata, self.ref_inputdata)
-
-        with pytest.raises(exc.TaskotronDirectiveError):
-            test_runner.do_single_action(ref_action)
-
-    def test_runner_do_single_dummy_action_pass_export(self):
-        ref_exportname = 'dummy_output'
-        ref_output = 'this is example output'
-        ref_action = {'name': 'Dummy Action',
-                      'dummy': {'result': 'pass', 'msg': ref_output},
-                      'export': '%s' % ref_exportname}
-
-        test_runner = runner.LocalRunner(self.ref_taskdata, self.ref_inputdata)
-
-        test_runner.do_single_action(ref_action)
-
-        assert test_runner.working_data[ref_exportname] == ref_output
-
-    def test_runner_do_single_dummy_action_pass_export_rendered_msg(self):
-        ref_exportname = 'dummy_output'
-        ref_message = 'This is a variable message'
-        ref_messagename = 'variable_messsage'
-        ref_action = {'name': 'Dummy Action',
-                      'dummy': {'result': 'pass',
-                                'msg': '${%s}' % ref_messagename},
-                      'export': '%s' % ref_exportname}
-
-        test_runner = runner.LocalRunner(self.ref_taskdata, self.ref_inputdata)
-        test_runner.working_data[ref_messagename] = ref_message
-
-        test_runner.do_single_action(ref_action)
-
-        assert test_runner.working_data[ref_exportname] == ref_message
-
-    def test_runner_do_single_dummy_action_exitcode_None(self):
-        ref_action = {'name': 'Return exitcode', 'exitcode': {'result_last': self.ref_yaml_passed}}
-
-        test_runner = runner.LocalRunner(self.ref_taskdata, self.ref_inputdata)
-
-        test_runner.do_single_action(ref_action)
-
-        assert test_runner.exitcode == exitcode_directive.SUCCESS
-
-    def test_runner_do_single_dummy_action_exitcode_success(self):
-        ref_action = {'name': 'Return exitcode', 'exitcode': {'result_last': self.ref_yaml_passed}}
-
-        test_runner = runner.LocalRunner(self.ref_taskdata, self.ref_inputdata)
-
-        test_runner.do_single_action(ref_action)
-
-        assert test_runner.exitcode == exitcode_directive.SUCCESS
-
-    def test_runner_do_single_dummy_action_exitcode_failure(self):
-        ref_action = {'name': 'Return exitcode', 'exitcode': {'result_last': self.ref_yaml_failed}}
-
-        test_runner = runner.LocalRunner(self.ref_taskdata, self.ref_inputdata)
-
-        test_runner.do_single_action(ref_action)
-
-        assert test_runner.exitcode == exitcode_directive.FAILURE
-
-    def test_runner_do_single_dummy_action_exitcode_failure_overwrite_success(self):
-        ref_action = {'name': 'Return exitcode', 'exitcode': {'result_last': self.ref_yaml_failed}}
-
-        test_runner = runner.LocalRunner(self.ref_taskdata, self.ref_inputdata)
-        test_runner.exitcode = exitcode_directive.SUCCESS
-        test_runner.do_single_action(ref_action)
-
-        assert test_runner.exitcode == exitcode_directive.FAILURE
-
-    def test_runner_do_single_dummy_action_exitcode_success_not_overwrite_failure(self):
-        ref_action = {'name': 'Return exitcode', 'exitcode': {'result_last': self.ref_yaml_passed}}
-
-        test_runner = runner.LocalRunner(self.ref_taskdata, self.ref_inputdata)
-        test_runner.exitcode = exitcode_directive.FAILURE
-        test_runner.do_single_action(ref_action)
-
-        assert test_runner.exitcode == exitcode_directive.FAILURE
-
-
-class TestLocalRunnerDoActions():
-
-    def setup_method(self, method):
-        self.ref_taskdata = {}
-        self.ref_inputdata = {}
-
-    def test_runner_do_single_dummy_pass(self):
-        ref_action = {'name': 'Dummy Action',
-                      'dummy': {'result': 'pass'}}
-        self.ref_taskdata = {'actions': [ref_action]}
-
-        test_runner = runner.LocalRunner(self.ref_taskdata, self.ref_inputdata)
-
-        test_runner.do_actions()
-
-    def test_runner_do_single_dummy_fail(self):
-        ref_action = {'name': 'Dummy Action',
-                      'dummy': {'result': 'fail'}}
-        self.ref_taskdata = {'actions': [ref_action]}
-
-        test_runner = runner.LocalRunner(self.ref_taskdata, self.ref_inputdata)
-
-        with pytest.raises(exc.TaskotronDirectiveError):
-            test_runner.do_actions()
-
-    def test_runner_do_multiple_dummy_fail(self):
-        ref_action = {'name': 'Dummy Action',
-                      'dummy': {'result': 'fail'}}
-        self.ref_taskdata = {'actions': [ref_action, ref_action]}
-
-        test_runner = runner.LocalRunner(self.ref_taskdata, self.ref_inputdata)
-
-        with pytest.raises(exc.TaskotronDirectiveError):
-            test_runner.do_actions()
-
-    def test_runner_do_multiple_dummy_pass_export_rendered_msg(self):
-        ref_exportname = 'dummy_output'
-        ref_message = 'This is a variable message'
-        ref_messagename = 'variable_messsage'
-        ref_action = {'name': 'Dummy Action',
-                      'dummy': {'result': 'pass',
-                                'msg': '${%s}' % ref_messagename},
-                      'export': '%s' % ref_exportname}
-
-        self.ref_taskdata = {'actions': [ref_action, ref_action]}
-
-        test_runner = runner.LocalRunner(self.ref_taskdata, self.ref_inputdata)
-        test_runner.working_data[ref_messagename] = ref_message
-
-        test_runner.do_actions()
-
-        assert test_runner.working_data[ref_exportname] == ref_message
-
-    def test_runner_fails_missing_action(self):
-        self.ref_taskdata = {'actions': None}
-
-        test_runner = runner.LocalRunner(self.ref_taskdata, self.ref_inputdata)
-
-        with pytest.raises(exc.TaskotronYamlError):
-            test_runner.do_actions()
-
-    def test_runner_fails_missing_task(self):
-        self.ref_taskdata = {}
-
-        test_runner = runner.LocalRunner(self.ref_taskdata, self.ref_inputdata)
-
-        with pytest.raises(exc.TaskotronYamlError):
-            test_runner.do_actions()
-
-
-class TestLocalRunnerRenderAction():
-
-    def setup_method(self, method):
-        self.ref_taskdata = {}
-        self.ref_inputdata = {}
-
-    def test_runner_render_single_variable(self):
-        ref_message = 'This is a variable message'
-        ref_messagename = 'variable_messsage'
-        ref_action = {'name': 'test dummy action', 'dummy':
-            {'result': 'pass', 'msg': '${%s}' % ref_messagename}}
-
-        test_runner = runner.LocalRunner(self.ref_taskdata, self.ref_inputdata)
-        test_runner.working_data[ref_messagename] = ref_message
-
-        test_rendered_action = test_runner._render_action(ref_action)
-
-        assert test_rendered_action['dummy']['msg'] == ref_message
-
-    def test_runner_render_list_variable(self):
-        ref_var = ['foo', 'bar']
-        ref_varname = 'testvar'
-        ref_action = {'name': 'test dummy action', 'dummy':
-            {'result': 'pass', 'msg': '${%s}' % ref_varname}}
-
-        test_runner = runner.LocalRunner(self.ref_taskdata, self.ref_inputdata)
-        test_runner.working_data[ref_varname] = ref_var
-
-        test_rendered_action = test_runner._render_action(ref_action)
-
-        assert test_rendered_action['dummy']['msg'] == ref_var
-
-    def test_runner_render_multi_variable(self):
-        ref_var1 = 'foo'
-        ref_var1name = 'testvar1'
-        ref_var2 = 'bar'
-        ref_var2name = 'testvar2'
-        ref_action = {'name': 'test dummy action', 'dummy':
-            {'result': 'pass', 'msg': '--foo ${%s} --bar ${%s}' %
-            (ref_var1name, ref_var2name)}}
-
-        test_runner = runner.LocalRunner(self.ref_taskdata, self.ref_inputdata)
-        test_runner.working_data[ref_var1name] = ref_var1
-        test_runner.working_data[ref_var2name] = ref_var2
-
-        test_rendered_action = test_runner._render_action(ref_action)
-
-        assert (test_rendered_action['dummy']['msg'] == '--foo %s --bar %s' %
-                (ref_var1, ref_var2))
-
-
-@pytest.mark.usefixtures('setup')
-class TestLocalRunnerProcessArgs():
-
-    @pytest.fixture
-    def setup(self, monkeypatch):
-        self.ref_uuid = '20150930_153933_359680'
-        self.ref_artifactsdir = '/dir/'
-        self.stub_config = Dingus()
-        self.stub_get_config = Dingus()
-
-        self.stub_get_config.return_value = self.stub_config
-        monkeypatch.setattr(config, 'get_config', self.stub_get_config)
-        self.stub_config.artifactsdir = self.ref_artifactsdir
-
-    def test_process_args_koji_build(self):
-        ref_input = { 'arch': ['x86_64'],
-                      'item': 'foo-1.2-3.fc99',
-                      'type': 'koji_build',
-                      'task': 'sometask.yml',
-                      'override': [],
-                      'uuid': self.ref_uuid}
-
-        ref_args = copy.deepcopy(ref_input)
-        ref_args['koji_build'] = 'foo-1.2-3.fc99'
-        ref_args['artifactsdir'] = self.ref_artifactsdir+self.ref_uuid
-
-        test_args = runner.process_args(ref_input)
-
-        assert test_args == ref_args
-
-    def test_process_args_bodhi_id(self):
-        ref_input = { 'arch': ['x86_64'],
-                      'item': 'foo-1.2-3.fc99',
-                      'type': 'bodhi_id',
-                      'task': 'sometask.yml',
-                      'override': [],
-                      'uuid': self.ref_uuid}
-
-        ref_args = copy.deepcopy(ref_input)
-        ref_args['bodhi_id'] = 'foo-1.2-3.fc99'
-        ref_args['artifactsdir'] = self.ref_artifactsdir+self.ref_uuid
-
-        test_args = runner.process_args(ref_input)
-
-        assert test_args == ref_args
-
-    def test_process_args_koji_tag(self):
-        ref_input = { 'arch': ['x86_64'],
-                      'item': 'dist-fc99-updates',
-                      'type': 'koji_tag',
-                      'task': 'sometask.yml',
-                      'override': [],
-                      'uuid': self.ref_uuid}
-
-        ref_args = copy.deepcopy(ref_input)
-        ref_args['koji_tag'] = 'dist-fc99-updates'
-        ref_args['artifactsdir'] = self.ref_artifactsdir+self.ref_uuid
-
-        test_args = runner.process_args(ref_input)
-
-        assert test_args == ref_args
-
-    def test_process_args_compose(self):
-        ref_input = { 'arch': [''],
-                      'item': 'rawhide',
-                      'type': 'compose',
-                      'task': 'sometask.yml',
-                      'override': [],
-                      'uuid': self.ref_uuid}
-
-        ref_args = copy.deepcopy(ref_input)
-        ref_args['compose'] = 'rawhide'
-        ref_args['artifactsdir'] = self.ref_artifactsdir+self.ref_uuid
-
-        test_args = runner.process_args(ref_input)
-
-        assert test_args == ref_args
-
-    def test_process_args_koji_tag_multiple_arches(self):
-        ref_input = { 'arch': ['x86_64', 'i386', 'noarch'],
-                      'item': 'dist-fc99-updates',
-                      'type': 'koji_tag',
-                      'task': 'sometask.yml',
-                      'override': [],
-                      'uuid': self.ref_uuid}
-
-        ref_args = copy.deepcopy(ref_input)
-        ref_args['koji_tag'] = 'dist-fc99-updates'
-        ref_args['artifactsdir'] = self.ref_artifactsdir+self.ref_uuid
-
-        test_args = runner.process_args(ref_input)
-
-        assert test_args == ref_args
-
-    def test_process_args_no_arch(self):
-        ref_input = { 'type': 'invalid',
-                      'arch': None,
-                      'override': [],
-                      'uuid': self.ref_uuid}
-        ref_args = copy.deepcopy(ref_input)
-        ref_args['arch'] = ['noarch']
-        ref_args['artifactsdir'] = self.ref_artifactsdir+self.ref_uuid
-
-        test_args = runner.process_args(ref_input)
-        assert test_args == ref_args
-
-
-class TestLocalRunnerCheckOverride():
-
-    def test_override_existing(self):
-        ref_input = { 'arch': ['x86_64'],
-                      'type': 'koji_build',
-                      'item': 'foo-1.2-3.fc99',
-                      'uuid': '20150220_134110_955656',
-                      'override': ["workdir='/some/dir/'"]}
-
-        ref_inputdata = runner.process_args(ref_input)
-
-        test_runner = runner.LocalRunner(Dingus(), ref_inputdata)
-        test_runner.do_actions = lambda: None
-        test_runner.run()
-
-        assert test_runner.arg_data['workdir'] == "/some/dir/"
-
-    def test_override_nonexisting(self):
-        ref_input = { 'arch': ['x86_64'],
-                      'type': 'koji_build',
-                      'item': 'foo-1.2-3.fc99',
-                      'uuid': '20150220_134110_955656',
-                      'override': ["friendship='is magic'"]}
-
-        ref_inputdata = runner.process_args(ref_input)
-
-        test_runner = runner.LocalRunner(Dingus(), ref_inputdata)
-        test_runner.do_actions = lambda: None
-        test_runner.run()
-
-        assert test_runner.arg_data['friendship'] == "is magic"
-
-
-@pytest.mark.usefixtures('setup')
-class TestLocalRunnerPrepareTask():
-
-    @pytest.fixture
-    def setup(self, monkeypatch):
-        self.install_stub = Dingus()
-        self.is_installed_stub = Dingus()
-        self.stub_config = Dingus()
-        self.stub_get_config = Dingus()
-
-        self.stub_get_config.return_value = self.stub_config
-        monkeypatch.setattr(rpm_utils, 'install', self.install_stub)
-        monkeypatch.setattr(rpm_utils, 'is_installed', self.is_installed_stub)
-        monkeypatch.setattr(config, 'get_config', self.stub_get_config)
-
-        self.test_runner = runner.LocalRunner({}, {'local': False, 'ssh': None})
-
-    def test_devel_profile_installed(self):
-        self.is_installed_stub.return_value = True
-        self.stub_config.profile = 'devel'
-
-        self.test_runner.prepare_task()
-
-        assert len(self.install_stub.calls()) == 0 and len(self.is_installed_stub.calls()) == 1
-
-    def test_devel_profile_not_installed(self):
-        self.is_installed_stub.return_value = False
-        self.stub_config.profile = 'devel'
-
-        with pytest.raises(exc.TaskotronError):
-            self.test_runner.prepare_task()
-
-        assert len(self.install_stub.calls()) == 0 and len(self.is_installed_stub.calls()) == 1
-
-    def test_prod_profile(self):
-        self.is_installed_stub.return_value = True
-        self.stub_config.profile = 'production'
-
-        self.test_runner.prepare_task()
-
-        assert len(self.install_stub.calls()) == 1 and len(self.is_installed_stub.calls()) == 0
-
-
-@pytest.mark.usefixtures('setup')
-class TestRunner():
-
-    @pytest.fixture
-    def setup(self, monkeypatch):
-        self.stub_local = Dingus()
-        self.stub_remote = Dingus()
-        monkeypatch.setattr(runner.Runner, '_run_locally', self.stub_local)
-        monkeypatch.setattr(runner.Runner, '_run_remotely', self.stub_remote)
-
-        self.stub_get_config = Dingus()
-        self.stub_config_local = Dingus()
-        self.stub_config_local.runtask_mode = config.RuntaskModeName.LOCAL
-        self.stub_config_remote = Dingus()
-        self.stub_config_remote.runtask_mode = config.RuntaskModeName.LIBVIRT
-
-        self.empty_args = {'local': None, 'libvirt': None, 'ssh': None, 'ssh_privkey': None,
-                           'artifactsdir': '/dir'}
-        self.fake_taskdata = {'environment': {'rpm': ['foo', 'bar']}}
-
-    def test_local_no_cli(self, monkeypatch):
-        '''run locally with local runmode in config and no override from cli'''
-        ref_inputdata = self.empty_args.copy()
-        self.stub_get_config.return_value = self.stub_config_local
-        monkeypatch.setattr(config, 'get_config', self.stub_get_config)
-
-        test_runner = runner.Runner(self.fake_taskdata, ref_inputdata)
-        assert test_runner.run_remotely is False
-
-        test_runner.run()
-        assert len(self.stub_local.calls()) == 1 and len(self.stub_remote.calls()) == 0
-
-    def test_remote_no_cli(self, monkeypatch):
-        '''run remotely with remote runmode in config and no override from cli'''
-        ref_inputdata = self.empty_args.copy()
-        self.stub_get_config.return_value = self.stub_config_remote
-        monkeypatch.setattr(config, 'get_config', self.stub_get_config)
-
-        test_runner = runner.Runner(self.fake_taskdata, ref_inputdata)
-        assert test_runner.run_remotely is True
-        assert test_runner.direct_ssh is False
-
-        test_runner.run()
-        assert len(self.stub_local.calls()) == 0 and len(self.stub_remote.calls()) == 1
-
-    def test_remote_override_local(self, monkeypatch):
-        '''run locally with remote runmode in config and local override from cli'''
-        ref_inputdata = self.empty_args.copy()
-        ref_inputdata.update({'local': True})
-        self.stub_get_config.return_value = self.stub_config_remote
-        monkeypatch.setattr(config, 'get_config', self.stub_get_config)
-
-        test_runner = runner.Runner(self.fake_taskdata, ref_inputdata)
-        assert test_runner.run_remotely is False
-
-        test_runner.run()
-        assert len(self.stub_local.calls()) == 1 and len(self.stub_remote.calls()) == 0
-
-    def test_local_override_ssh(self, monkeypatch):
-        '''run remotely with local runmode in config and ssh override from cli'''
-        ref_inputdata = self.empty_args.copy()
-        ref_inputdata.update({'ssh': 'david@HAL9000'})
-        self.stub_get_config.return_value = self.stub_config_local
-        monkeypatch.setattr(config, 'get_config', self.stub_get_config)
-
-        test_runner = runner.Runner(self.fake_taskdata, ref_inputdata)
-        assert test_runner.run_remotely is True
-        assert test_runner.direct_ssh is True
-
-        test_runner.run()
-        assert len(self.stub_local.calls()) == 0 and len(self.stub_remote.calls()) == 1
-
-    def test_local_override_libvirt(self, monkeypatch):
-        '''run remotely with local runmode in config and libvirt override from cli'''
-        ref_inputdata = self.empty_args.copy()
-        ref_inputdata.update({'libvirt': True})
-        self.stub_get_config.return_value = self.stub_config_local
-        monkeypatch.setattr(config, 'get_config', self.stub_get_config)
-
-        test_runner = runner.Runner(self.fake_taskdata, ref_inputdata)
-        assert test_runner.run_remotely is True
-        assert test_runner.direct_ssh is False
-
-        test_runner.run()
-        assert len(self.stub_local.calls()) == 0 and len(self.stub_remote.calls()) == 1
-
-
-class TestRunnerLocalRunner():
-    def test_local_runner_used(self, monkeypatch):
-        ref_inputdata = {'local': True, 'ssh': None, 'ssh_privkey': None, 'artifactsdir': '/dir'}
-        ref_taskdata = {'environment': {'rpm': ['foo', 'bar']}}
-
-        stub_local_runner = Dingus()
-        monkeypatch.setattr(runner, 'LocalRunner', stub_local_runner)
-
-        test_runner = runner.Runner(ref_taskdata, ref_inputdata)
-
-        test_runner.run()
-
-        # check that LocalRunner was instantiated
-        assert len(stub_local_runner.calls()) == 1
-
-    def test_local_runner_properly_used(self, monkeypatch):
-        ref_inputdata = {'local': True, 'ssh': None, 'ssh_privkey': None, 'artifactsdir': '/dir'}
-        ref_taskdata = {'environment': {'rpm': ['foo', 'bar']}}
-
-        stub_local_runner_object = Dingus()
-        stub_local_runner = Dingus()
-        stub_local_runner.return_value = stub_local_runner_object
-        monkeypatch.setattr(runner, 'LocalRunner', stub_local_runner)
-
-        test_runner = runner.Runner(ref_taskdata, ref_inputdata)
-
-        test_runner.run()
-
-        methods_called = [call[0] for call in stub_local_runner_object.calls()]
-
-        assert len(methods_called) == 2
-        assert 'prepare_task' in methods_called and 'run' in methods_called
-
-
-@pytest.mark.usefixtures('setup')
-class TestCheckArgs():
-
-    @pytest.fixture
-    def setup(self):
-        self.empty_args = {'local': None, 'libvirt': None, 'ssh': None}
-
-    def test_runmode_exclusive(self):
-        '''allow only single runmode selection'''
-        ref_inputdata_bad = [{'local': True, 'ssh': 'user@machine'},
-                             {'local': True, 'libvirt': True},
-                             {'libvirt': True, 'ssh': 'user@machine'},
-                             {'local': True, 'ssh': 'user@machine', 'libvirt': True}]
-        ref_inputdata_good = [{'local': True},
-                              {'ssh': 'user@machine'},
-                              {'libvirt': True},
-                              {}]
-
-        for ref_inputdata in ref_inputdata_bad:
-            stub_error = Dingus()
-            stub_parser = Dingus()
-            stub_parser.error = stub_error
-            args = self.empty_args.copy()
-            args.update(ref_inputdata)
-            runner.check_args(stub_parser, args)
-            assert len(stub_error.calls()) == 1
-
-        for ref_inputdata in ref_inputdata_good:
-            stub_error = Dingus()
-            stub_parser = Dingus()
-            stub_parser.error = stub_error
-            args = self.empty_args.copy()
-            args.update(ref_inputdata)
-            runner.check_args(stub_parser, args)
-            assert len(stub_error.calls()) == 0
-
-    def test_ssh_bad_usermachine(self):
-        '''fail on invalid user@machine[:port] specifier'''
-        ref_inputdata = {'ssh': 'user#machine'}
-
-        stub_error = Dingus()
-        stub_parser = Dingus()
-        stub_parser.error = stub_error
-        args = self.empty_args
-        args.update(ref_inputdata)
-        runner.check_args(stub_parser, args)
-
-        assert len(stub_error.calls()) == 1

