← Back to team overview

cloud-init-dev team mailing list archive

[Merge] ~wesley-wiedenmeier/cloud-init:integration-testing into cloud-init:master

 

Wesley Wiedenmeier has proposed merging ~wesley-wiedenmeier/cloud-init:integration-testing into cloud-init:master.

Requested reviews:
  cloud init development team (cloud-init-dev)

For more details, see:
https://code.launchpad.net/~wesley-wiedenmeier/cloud-init/+git/cloud-init/+merge/308218

integration-testing updates

 - enable additional distros
   - full chart of enabled/disabled at: goo.gl/q78sY8
 - allow use of either pylxd 2.1.3 or 2.2
   - detected at runtime during instance.execute()
   - when using 2.2 the test suite cannot complete large
     runs without hitting the pylxd too many open files
     issue, so 2.2 should only be used for development
   - when using 2.1.3, the test suite will not have proper
     error handling during setup phase, as 2.1.3 does not
     return command exit codes. this is adequate for use on
     jenkins until the fix for 2.2 is merged in upstream
 - refactor image setup code
   - improve error handling throughout
   - change behavior of --upgrade setup_image to only
     upgrade cloud-init
   - add --upgrade-all setup_image flag to upgrade all
     packages on system
   - clean up detection of cloud-init version
   - output cloud-init version at end of setup_image
   - use 'yum install' rather than 'yum update' in
     setup_image.upgrade in case cloud-init not installed
   - update help in setup_image args
 - clean up image config
   - new image config format allows finer control over
     platform specific image config, with less verbosity
   - clean up handling of image config throughout platform
   - added additional releases and distros
   - re-ordered releases.yaml in order of release date, and
     added EOL comments for future reference
   - allow image config to override setup options with
     'setup_overrides'
   - allow image config to specify preserving base images
     for lxd
   - switch to using images.linuxcontainers.org from using
     ubuntu daily images
 - improve waiting for system to boot
   - separated out determining if the system has booted from
     determining if cloud-init has finished
   - do not wait for cloud-init during setup phases, as it
     may not be installed
   - use 'systemctl is-system-running' or 'runlevel'
     depending on init system
   - poll for system ready inside the image rather than
     through instance.execute() to make pylxd too many open
     files error take longer to strike
   - clean up code for handling waiting
 - update lxd image metadata and templates if necessary
   - many lxd images that do not come with cloud-init
     installed do not have the necessary templates to write
     a nocloud seed into the image
   - if necessary, export image, extract metadata, add new
     template scripts, and import as new image
 - add new 'bddeb' feature to build a deb from working tree
   - new command 'bddeb' to build a deb in clean container
     from current working tree, avoiding having to commit
     code that may be broken to test and installing build
     deps on the host
   - new commands 'tree_collect' and 'tree_run' that collect
     and run using the version of cloud-init in the current
     working tree by first running 'bddeb' then 'collect' or
     'run' using the new deb
 - add new tox environments to run tests
   - new versions of pylxd only available through pip on
     xenial, best to avoid using pip outside a venv
   - environments 'citest' and 'citest_new_pylxd' provide
     entry points into the cli, allowing use of any test
     commands, using pylxd 2.1.3 and 2.2.2 respectively
   - environment 'citest_tree_run' runs tests using the
     current working tree, useful for testing during
     development
-- 
Your team cloud init development team is requested to review the proposed merge of ~wesley-wiedenmeier/cloud-init:integration-testing into cloud-init:master.
diff --git a/tests/cloud_tests/__init__.py b/tests/cloud_tests/__init__.py
index 099c357..7959bd9 100644
--- a/tests/cloud_tests/__init__.py
+++ b/tests/cloud_tests/__init__.py
@@ -6,6 +6,7 @@ import os
 BASE_DIR = os.path.dirname(os.path.abspath(__file__))
 TESTCASES_DIR = os.path.join(BASE_DIR, 'testcases')
 TEST_CONF_DIR = os.path.join(BASE_DIR, 'configs')
+TREE_BASE = os.sep.join(BASE_DIR.split(os.sep)[:-2])
 
 
 def _initialize_logging():
diff --git a/tests/cloud_tests/__main__.py b/tests/cloud_tests/__main__.py
index ef7d187..74f29f6 100644
--- a/tests/cloud_tests/__main__.py
+++ b/tests/cloud_tests/__main__.py
@@ -2,11 +2,9 @@
 
 import argparse
 import logging
-import shutil
 import sys
-import tempfile
 
-from tests.cloud_tests import (args, collect, manage, verify)
+from tests.cloud_tests import args, bddeb, collect, manage, run_funcs, verify
 from tests.cloud_tests import LOG
 
 
@@ -22,28 +20,6 @@ def configure_log(args):
     LOG.setLevel(level)
 
 
-def run(args):
-    """
-    run full test suite
-    """
-    failed = 0
-    args.data_dir = tempfile.mkdtemp(prefix='cloud_test_data_')
-    LOG.debug('using tmpdir %s', args.data_dir)
-    try:
-        failed += collect.collect(args)
-        failed += verify.verify(args)
-    except Exception:
-        failed += 1
-        raise
-    finally:
-        # TODO: make this configurable via environ or cmdline
-        if failed:
-            LOG.warn('some tests failed, leaving data in %s', args.data_dir)
-        else:
-            shutil.rmtree(args.data_dir)
-    return failed
-
-
 def main():
     """
     entry point for cloud test suite
@@ -80,9 +56,12 @@ def main():
     # run handler
     LOG.debug('running with args: %s\n', parsed)
     return {
+        'bddeb': bddeb.bddeb,
         'collect': collect.collect,
         'create': manage.create,
-        'run': run,
+        'run': run_funcs.run,
+        'tree_collect': run_funcs.tree_collect,
+        'tree_run': run_funcs.tree_run,
         'verify': verify.verify,
     }[parsed.subcmd](parsed)
 
diff --git a/tests/cloud_tests/args.py b/tests/cloud_tests/args.py
index b68cc98..c6f4bb3 100644
--- a/tests/cloud_tests/args.py
+++ b/tests/cloud_tests/args.py
@@ -3,17 +3,35 @@
 import os
 
 from tests.cloud_tests import config, util
-from tests.cloud_tests import LOG
+from tests.cloud_tests import LOG, TREE_BASE
 
 ARG_SETS = {
+    'BDDEB': (
+        (('--bddeb-args',),
+         {'help': 'args to pass through to bddeb',
+          'action': 'store', 'default': None, 'required': False}),
+        (('--python2',),
+         {'help': 'use python2 build deps', 'action': 'store_true',
+          'default': False, 'required': False}),
+        (('--build-os',),
+         {'help': 'OS to use as build system (default is xenial)',
+          'action': 'store', 'choices': config.ENABLED_DISTROS,
+          'default': 'xenial', 'required': False}),
+        (('--build-platform',),
+         {'help': 'platform to use for build system (default is lxd)',
+          'action': 'store', 'choices': config.ENABLED_PLATFORMS,
+          'default': 'lxd', 'required': False}),
+        (('--cloud-init',),
+         {'help': 'path to base of cloud-init tree', 'metavar': 'DIR',
+          'action': 'store', 'required': False, 'default': TREE_BASE}),),
     'COLLECT': (
         (('-p', '--platform'),
          {'help': 'platform(s) to run tests on', 'metavar': 'PLATFORM',
-          'action': 'append', 'choices': config.list_enabled_platforms(),
+          'action': 'append', 'choices': config.ENABLED_PLATFORMS,
           'default': []}),
         (('-n', '--os-name'),
          {'help': 'the name(s) of the OS(s) to test', 'metavar': 'NAME',
-          'action': 'append', 'choices': config.list_enabled_distros(),
+          'action': 'append', 'choices': config.ENABLED_DISTROS,
           'default': []}),
         (('-t', '--test-config'),
          {'help': 'test config file(s) to use', 'metavar': 'FILE',
@@ -42,6 +60,10 @@ ARG_SETS = {
         (('-d', '--data-dir'),
          {'help': 'directory to store test data in',
           'action': 'store', 'metavar': 'DIR', 'required': True}),),
+    'OUTPUT_DEB': (
+        (('--deb',),
+         {'help': 'path to write output deb to', 'metavar': 'FILE',
+          'action': 'store', 'required': True}),),
     'RESULT': (
         (('-r', '--result'),
          {'help': 'file to write results to',
@@ -61,15 +83,25 @@ ARG_SETS = {
          {'help': 'ppa to enable (implies -u)', 'metavar': 'NAME',
           'action': 'store'}),
         (('-u', '--upgrade'),
-         {'help': 'upgrade before starting tests', 'action': 'store_true',
-          'default': False}),),
+         {'help': 'upgrade or install cloud-init from repo',
+          'action': 'store_true', 'default': False}),
+        (('--upgrade-full',),
+         {'help': 'do full system upgrade from repo (implies -u)',
+          'action': 'store_true', 'default': False}),),
+
 }
 
 SUBCMDS = {
+    'bddeb': ('build cloud-init deb from tree',
+              ('BDDEB', 'OUTPUT_DEB', 'INTERFACE')),
     'collect': ('collect test data',
                 ('COLLECT', 'INTERFACE', 'OUTPUT', 'RESULT', 'SETUP')),
     'create': ('create new test case', ('CREATE', 'INTERFACE')),
     'run': ('run test suite', ('COLLECT', 'INTERFACE', 'RESULT', 'SETUP')),
+    'tree_collect': ('collect using current working tree',
+                     ('BDDEB', 'COLLECT', 'INTERFACE', 'OUTPUT', 'RESULT')),
+    'tree_run': ('run using current working tree',
+                 ('BDDEB', 'COLLECT', 'INTERFACE', 'RESULT')),
     'verify': ('verify test data', ('INTERFACE', 'OUTPUT', 'RESULT')),
 }
 
@@ -81,6 +113,24 @@ def _empty_normalizer(args):
     return args
 
 
+def normalize_bddeb_args(args):
+    """
+    normalize BDDEB arguments
+    args: parsed args
+    return_value: updated args, or None if errors encountered
+    """
+    # make sure cloud-init dir is accessible
+    if not (args.cloud_init and os.path.isdir(args.cloud_init)):
+        LOG.error('invalid cloud-init tree path')
+        return None
+
+    # if --python2 passed into bddeb, use for deps
+    if args.bddeb_args and '--python2' in args.bddeb_args:
+        args.python2 = True
+
+    return args
+
+
 def normalize_create_args(args):
     """
     normalize CREATE arguments
@@ -94,7 +144,7 @@ def normalize_create_args(args):
     if os.path.exists(config.name_to_path(args.name)):
         msg = 'test: {} already exists'.format(args.name)
         if args.force:
-            LOG.warn('%s but ignoring due to --force', msg)
+            LOG.warning('%s but ignoring due to --force', msg)
         else:
             LOG.error(msg)
             return None
@@ -121,15 +171,15 @@ def normalize_collect_args(args):
     """
     # platform should default to all supported
     if len(args.platform) == 0:
-        args.platform = config.list_enabled_platforms()
+        args.platform = config.ENABLED_PLATFORMS
     args.platform = util.sorted_unique(args.platform)
 
     # os name should default to all enabled
     # if os name is provided ensure that all provided are supported
     if len(args.os_name) == 0:
-        args.os_name = config.list_enabled_distros()
+        args.os_name = config.ENABLED_DISTROS
     else:
-        supported = config.list_enabled_distros()
+        supported = config.ENABLED_DISTROS
         invalid = [os_name for os_name in args.os_name
                    if os_name not in supported]
         if len(invalid) != 0:
@@ -185,6 +235,17 @@ def normalize_output_args(args):
     return args
 
 
+def normalize_output_deb_args(args):
+    """
+    normalize OUTPUT_DEB arguments
+    args: parsed args
+    return_value: updated args, or None if erros occurred
+    """
+    # make sure to use abspath for deb
+    args.deb = os.path.abspath(args.deb)
+    return args
+
+
 def normalize_setup_args(args):
     """
     normalize SETUP arguments
@@ -210,10 +271,12 @@ def normalize_setup_args(args):
 
 
 NORMALIZERS = {
+    'BDDEB': normalize_bddeb_args,
     'COLLECT': normalize_collect_args,
     'CREATE': normalize_create_args,
     'INTERFACE': _empty_normalizer,
     'OUTPUT': normalize_output_args,
+    'OUTPUT_DEB': normalize_output_deb_args,
     'RESULT': _empty_normalizer,
     'SETUP': normalize_setup_args,
 }
diff --git a/tests/cloud_tests/bddeb.py b/tests/cloud_tests/bddeb.py
new file mode 100644
index 0000000..2006c17
--- /dev/null
+++ b/tests/cloud_tests/bddeb.py
@@ -0,0 +1,144 @@
+# This file is part of cloud-init. See LICENSE file for license information.
+
+from tests.cloud_tests import (config, LOG, setup_image, util)
+from tests.cloud_tests import (platforms, images, snapshots, instances)
+from tests.cloud_tests.stage import (PlatformComponent, run_stage, run_single)
+
+from cloudinit import util as c_util
+
+from functools import partial
+import os
+
+build_deps = ['devscripts', 'equivs', 'git', 'tar']
+source_archive = 'deb-src http://us.archive.ubuntu.com/ubuntu {release} main'
+
+
+def _out(cmd_res):
+    """
+    get clean output from cmd result
+    """
+    return cmd_res[0].strip()
+
+
+def build_deb(args, instance):
+    """
+    build deb on system and copy out to location at args.deb
+    args: cmdline arguments
+    return_value: tuple of results and fail count
+    """
+    # make sure that remote system has a deb-src source enabled
+    sources = instance.read_data('/etc/apt/sources.list', decode=True)
+    if not any(l for l in sources.splitlines() if l.startswith('deb-src')):
+        repo = source_archive.format(release=instance.properties['release'])
+        args = util.update_args(args, {'repo': repo}, preserve_old=True)
+        setup_image.enable_repo(args, instance)
+
+    # update remote system package list and install build deps
+    LOG.debug('installing build deps')
+    pkgs = ' '.join(build_deps)
+    cmd = 'apt-get update && apt-get install --yes {}'.format(pkgs)
+    instance.execute(['/bin/sh', '-c', cmd])
+    instance.execute(['mk-build-deps', '--install', '-t',
+                      'apt-get --no-install-recommends --yes', 'cloud-init'])
+
+    # local tmpfile that must be deleted
+    local_tarball = _out(c_util.subp(['mktemp'], capture=True))
+
+    try:
+        # paths to use in remote system
+        remote_tarball = _out(instance.execute(['mktemp']))
+        extract_dir = _out(instance.execute(['mktemp', '--directory']))
+        bddeb_path = os.path.join(extract_dir, 'packages', 'bddeb')
+        read_deps = os.path.join(extract_dir, 'tools', 'read-dependencies')
+        test_reqs = os.path.join(extract_dir, 'test-requirements.txt')
+        output_link = '/cloud-init_all.deb'
+        git_env = {
+            'GIT_DIR': os.path.join(extract_dir, '.git'),
+            'GIT_WORK_TREE': extract_dir,
+            'GIT_AUTHOR_NAME': 'root',
+            'EMAIL': 'root@tmp.instance',
+        }
+
+        # create a tarball of cloud init tree and copy to remote system
+        LOG.debug('creating tarball of cloud-init at: %s', local_tarball)
+        c_util.subp(['tar', 'cf', local_tarball, '--owner', 'root',
+                     '--group', 'root', '-C', args.cloud_init, '.'])
+        LOG.debug('copying to remote system at: %s', remote_tarball)
+        instance.push_file(local_tarball, remote_tarball)
+
+        # extract tarball in remote system and commit anything uncommitted
+        LOG.debug('extracting tarball in remote system at: %s', extract_dir)
+        instance.execute(['tar', 'xf', remote_tarball, '-C', extract_dir])
+        instance.execute(['git', 'commit', '-a', '-m', 'tmp', '--allow-empty'],
+                         env=git_env)
+
+        # install test requirements (build may fail without)
+        LOG.debug('installing additional build deps')
+        reqs = _out(instance.execute([read_deps, test_reqs])).split()
+        pkgs = [('python-' if args.python2 else 'python3-') + r for r in reqs]
+        instance.execute(['apt-get', 'install', '--yes'] + pkgs)
+
+        # build the deb, ignoring missing deps (flake8)
+        LOG.debug('building deb in remote system at: %s', output_link)
+        bddeb_args = args.bddeb_args.split() if args.bddeb_args else []
+        instance.execute([bddeb_path, '-d'] + bddeb_args, env=git_env)
+
+        # copy the deb back to the host system
+        LOG.debug('copying built deb to host at: %s', args.deb)
+        instance.pull_file(output_link, args.deb)
+
+    finally:
+        os.remove(local_tarball)
+
+
+def setup_build(args):
+    """
+    set build system up then run build
+    args: cmdline arguments
+    return_value: tuple of results and fail count
+    """
+    res = ({}, 1)
+
+    # set up platform
+    LOG.info('setting up platform: %s', args.build_platform)
+    platform_config = config.load_platform_config(args.build_platform)
+    platform_call = partial(platforms.get_platform, args.build_platform,
+                            platform_config)
+    with PlatformComponent(platform_call) as platform:
+
+        # set up image
+        LOG.info('acquiring image for os: %s', args.build_os)
+        img_conf = config.load_os_config(args.build_platform, args.build_os)
+        image_call = partial(images.get_image, platform, img_conf)
+        with PlatformComponent(image_call) as image:
+
+            # set up snapshot
+            snapshot_call = partial(snapshots.get_snapshot, image)
+            with PlatformComponent(snapshot_call) as snapshot:
+
+                # create instance with cloud-config to set it up
+                LOG.info('creating instance to build deb in')
+                empty_cloud_config = "#cloud-config\n{}"
+                instance_call = partial(
+                    instances.get_instance, snapshot, empty_cloud_config,
+                    use_desc='build cloud-init deb')
+                with PlatformComponent(instance_call) as instance:
+
+                    # build the deb
+                    res = run_single('build deb on system',
+                                     partial(build_deb, args, instance))
+
+    return res
+
+
+def bddeb(args):
+    """
+    entry point for build deb
+    args: cmdline arguments
+    return_value: fail count
+    """
+    LOG.info('preparing to build cloud-init deb')
+    (res, failed) = run_stage('build deb', [partial(setup_build, args)])
+    return failed
+
+# vi: ts=4 expandtab
diff --git a/tests/cloud_tests/collect.py b/tests/cloud_tests/collect.py
index 68b47d7..93ce603 100644
--- a/tests/cloud_tests/collect.py
+++ b/tests/cloud_tests/collect.py
@@ -18,8 +18,10 @@ def collect_script(instance, base_dir, script, script_name):
     return_value: None, may raise errors
     """
     LOG.debug('running collect script: %s', script_name)
-    util.write_file(os.path.join(base_dir, script_name),
-                    instance.run_script(script))
+    (out, err, exit) = instance.run_script(
+        script, ignore_errors=True,
+        description='collect: {}'.format(script_name))
+    util.write_file(os.path.join(base_dir, script_name), out)
 
 
 def collect_test_data(args, snapshot, os_name, test_name):
@@ -39,13 +41,10 @@ def collect_test_data(args, snapshot, os_name, test_name):
     test_scripts = test_config['collect_scripts']
     test_output_dir = os.sep.join(
         (args.data_dir, snapshot.platform_name, os_name, test_name))
-    boot_timeout = (test_config.get('boot_timeout')
-                    if isinstance(test_config.get('boot_timeout'), int) else
-                    snapshot.config.get('timeout'))
 
     # if test is not enabled, skip and return 0 failures
     if not test_config.get('enabled', False):
-        LOG.warn('test config %s is not enabled, skipping', test_name)
+        LOG.warning('test config %s is not enabled, skipping', test_name)
         return ({}, 0)
 
     # create test instance
@@ -56,7 +55,7 @@ def collect_test_data(args, snapshot, os_name, test_name):
     LOG.info('collecting test data for test: %s', test_name)
     with component as instance:
         start_call = partial(run_single, 'boot instance', partial(
-            instance.start, wait=True, wait_time=boot_timeout))
+            instance.start, wait=True, wait_for_cloud_init=True))
         collect_calls = [partial(run_single, 'script {}'.format(script_name),
                                  partial(collect_script, instance,
                                          test_output_dir, script, script_name))
@@ -100,10 +99,8 @@ def collect_image(args, platform, os_name):
     """
     res = ({}, 1)
 
-    os_config = config.load_os_config(os_name)
-    if not os_config.get('enabled'):
-        raise ValueError('OS {} not enabled'.format(os_name))
-
+    os_config = config.load_os_config(
+        platform.platform_name, os_name, require_enabled=True)
     component = PlatformComponent(
         partial(images.get_image, platform, os_config))
 
@@ -126,10 +123,8 @@ def collect_platform(args, platform_name):
     """
     res = ({}, 1)
 
-    platform_config = config.load_platform_config(platform_name)
-    if not platform_config.get('enabled'):
-        raise ValueError('Platform {} not enabled'.format(platform_name))
-
+    platform_config = config.load_platform_config(
+        platform_name, require_enabled=True)
     component = PlatformComponent(
         partial(platforms.get_platform, platform_name, platform_config))
 
diff --git a/tests/cloud_tests/config.py b/tests/cloud_tests/config.py
index f3a13c9..01cddf7 100644
--- a/tests/cloud_tests/config.py
+++ b/tests/cloud_tests/config.py
@@ -14,6 +14,20 @@ RELEASES_CONF = os.path.join(BASE_DIR, 'releases.yaml')
 TESTCASE_CONF = os.path.join(BASE_DIR, 'testcases.yaml')
 
 
+def get(base, key):
+    """
+    get config entry 'key' from base, ensuring is dictionary
+    """
+    return base[key] if key in base and base[key] is not None else {}
+
+
+def enabled(config):
+    """
+    test if config item is enabled
+    """
+    return isinstance(config, dict) and config.get('enabled', False)
+
+
 def path_to_name(path):
     """
     convert abs or rel path to test config to path under configs/
@@ -61,22 +75,39 @@ def merge_config(base, override):
     return res
 
 
-def load_platform_config(platform):
+def load_platform_config(platform_name, require_enabled=False):
     """
     load configuration for platform
+    platform_name: name of platform to retrieve config for
+    require_enabled: if true, raise error if 'enabled' not True
+    return_value: config dict
     """
     main_conf = c_util.read_conf(PLATFORM_CONF)
-    return merge_config(main_conf.get('default_platform_config'),
-                        main_conf.get('platforms')[platform])
+    conf = merge_config(main_conf['default_platform_config'],
+                        main_conf['platforms'][platform_name])
+    if require_enabled and not enabled(conf):
+        raise ValueError('Platform is not enabled')
+    return conf
 
 
-def load_os_config(os_name):
+def load_os_config(platform_name, os_name, require_enabled=False):
     """
     load configuration for os
+    platform_name: platform name to load os config for
+    os_name: name of os to retrieve config for
+    require_enabled: if true, raise error if 'enabled' not True
+    return_value: config dict
     """
     main_conf = c_util.read_conf(RELEASES_CONF)
-    return merge_config(main_conf.get('default_release_config'),
-                        main_conf.get('releases')[os_name])
+    default = main_conf['default_release_config']
+    image = main_conf['releases'][os_name]
+    conf = merge_config(merge_config(get(default, 'default'),
+                                     get(default, platform_name)),
+                        merge_config(get(image, 'default'),
+                                     get(image, platform_name)))
+    if require_enabled and not enabled(conf):
+        raise ValueError('OS is not enabled')
+    return conf
 
 
 def load_test_config(path):
@@ -91,16 +122,22 @@ def list_enabled_platforms():
     """
     list all platforms enabled for testing
     """
-    platforms = c_util.read_conf(PLATFORM_CONF).get('platforms')
-    return [k for k, v in platforms.items() if v.get('enabled')]
+    platforms = get(c_util.read_conf(PLATFORM_CONF), 'platforms')
+    return [k for k, v in platforms.items() if enabled(v)]
 
 
-def list_enabled_distros():
+def list_enabled_distros(platforms):
     """
-    list all distros enabled for testing
+    list all distros enabled for testing on specified platforms
     """
-    releases = c_util.read_conf(RELEASES_CONF).get('releases')
-    return [k for k, v in releases.items() if v.get('enabled')]
+
+    def platform_has_enabled(config):
+        return any(enabled(merge_config(get(config, 'default'),
+                                        get(config, platform)))
+                   for platform in platforms)
+
+    releases = get(c_util.read_conf(RELEASES_CONF), 'releases')
+    return [k for k, v in releases.items() if platform_has_enabled(v)]
 
 
 def list_test_configs():
@@ -110,4 +147,8 @@ def list_test_configs():
     return [os.path.abspath(f) for f in
             glob.glob(os.sep.join((TEST_CONF_DIR, '*', '*.yaml')))]
 
+
+ENABLED_PLATFORMS = list_enabled_platforms()
+ENABLED_DISTROS = list_enabled_distros(ENABLED_PLATFORMS)
+
 # vi: ts=4 expandtab
diff --git a/tests/cloud_tests/images/base.py b/tests/cloud_tests/images/base.py
index 394b11f..e61a928 100644
--- a/tests/cloud_tests/images/base.py
+++ b/tests/cloud_tests/images/base.py
@@ -7,13 +7,14 @@ class Image(object):
     """
     platform_name = None
 
-    def __init__(self, name, config, platform):
+    def __init__(self, platform, config):
         """
-        setup
+        Set up image
+        platform: platform object
+        config: image configuration
         """
-        self.name = name
-        self.config = config
         self.platform = platform
+        self.config = config
 
     def __str__(self):
         """
@@ -28,10 +29,16 @@ class Image(object):
         """
         raise NotImplementedError
 
-    # FIXME: instead of having execute and push_file and other instance methods
-    #        here which pass through to a hidden instance, it might be better
-    #        to expose an instance that the image can be modified through
-    def execute(self, command, stdin=None, stdout=None, stderr=None, env={}):
+    @property
+    def setup_overrides(self):
+        """
+        setup options that need to be overridden for the image
+        return_value: dictionary to update args with
+        """
+        # NOTE: more sophisticated options may be requied at some point
+        return self.config.get('setup_overrides', {})
+
+    def execute(self, *args, **kwargs):
         """
         execute command in image, modifying image
         """
@@ -43,7 +50,7 @@ class Image(object):
         """
         raise NotImplementedError
 
-    def run_script(self, script):
+    def run_script(self, *args, **kwargs):
         """
         run script in image, modifying image
         return_value: script output
diff --git a/tests/cloud_tests/images/lxd.py b/tests/cloud_tests/images/lxd.py
index 7a41614..e8c59c9 100644
--- a/tests/cloud_tests/images/lxd.py
+++ b/tests/cloud_tests/images/lxd.py
@@ -2,6 +2,10 @@
 
 from tests.cloud_tests.images import base
 from tests.cloud_tests.snapshots import lxd as lxd_snapshot
+from tests.cloud_tests import util
+
+import os
+import shutil
 
 
 class LXDImage(base.Image):
@@ -10,27 +14,44 @@ class LXDImage(base.Image):
     """
     platform_name = "lxd"
 
-    def __init__(self, name, config, platform, pylxd_image):
+    def __init__(self, platform, config, pylxd_image):
         """
-        setup
+        Set up image
+        platform: platform object
+        config: image configuration
         """
-        self.platform = platform
-        self._pylxd_image = pylxd_image
+        self.modified = False
         self._instance = None
-        super(LXDImage, self).__init__(name, config, platform)
+        self._pylxd_image = None
+        self.pylxd_image = pylxd_image
+        super(LXDImage, self).__init__(platform, config)
 
     @property
     def pylxd_image(self):
-        self._pylxd_image.sync()
+        if self._pylxd_image:
+            self._pylxd_image.sync()
         return self._pylxd_image
 
+    @pylxd_image.setter
+    def pylxd_image(self, pylxd_image):
+        if self._instance:
+            self._instance.destroy()
+            self._instance = None
+        if (self._pylxd_image and
+                (self._pylxd_image is not pylxd_image) and
+                (not self.config.get('cache_base_image') or self.modified)):
+            self._pylxd_image.delete(wait=True)
+        self.modified = False
+        self._pylxd_image = pylxd_image
+
     @property
     def instance(self):
         if not self._instance:
             self._instance = self.platform.launch_container(
-                image=self.pylxd_image.fingerprint,
-                image_desc=str(self), use_desc='image-modification')
-        self._instance.start(wait=True, wait_time=self.config.get('timeout'))
+                self.properties, self.config, use_desc='image-modification',
+                image_desc=str(self), image=self.pylxd_image.fingerprint)
+        self.modified = True
+        self._instance.start()
         return self._instance
 
     @property
@@ -46,6 +67,78 @@ class LXDImage(base.Image):
             'release': properties.get('release'),
         }
 
+    def export_image(self, output_dir):
+        """
+        export image from lxd image store to (split) tarball on disk
+        output_dir: dir to store tarballs in
+        return_value: tuple of path to metadata tarball and rootfs tarball
+        """
+        # pylxd's image export feature doesn't do split exports, so use cmdline
+        util.subp(['lxc', 'image', 'export', self.pylxd_image.fingerprint,
+                   output_dir], capture=True)
+        tarballs = [p for p in os.listdir(output_dir) if p.endswith('tar.xz')]
+        metadata = os.path.join(
+            output_dir, next(p for p in tarballs if p.startswith('meta-')))
+        rootfs = os.path.join(
+            output_dir, next(p for p in tarballs if not p.startswith('meta-')))
+        return (metadata, rootfs)
+
+    def import_image(self, metadata, rootfs):
+        """
+        import image to lxd image store from (split) tarball on disk
+        note, this will replace and delete the current pylxd_image
+        metadata: metadata tarball
+        rootfs: rootfs tarball
+        return_value: imported image fingerprint
+        """
+        alias = util.gen_instance_name(
+            image_desc=str(self), use_desc='update-metadata')
+        util.subp(['lxc', 'image', 'import', metadata, rootfs,
+                   '--alias', alias], capture=True)
+        self.pylxd_image = self.platform.query_image_by_alias(alias)
+        return self.pylxd_image.fingerprint
+
+    def update_templates(self, template_config, template_data):
+        """
+        update the image's template configuration
+        note, this will replace and delete the current pylxd_image
+        template_config: config overrides for template portion of metadata
+        template_data: template data to place into templates/
+        """
+        # set up tmp files
+        export_dir = util.tmpdir()
+        extract_dir = util.tmpdir()
+        new_metadata = os.path.join(export_dir, 'new-meta.tar.xz')
+        metadata_yaml = os.path.join(extract_dir, 'metadata.yaml')
+        template_dir = os.path.join(extract_dir, 'templates')
+
+        try:
+            # extract old data
+            (metadata, rootfs) = self.export_image(export_dir)
+            shutil.unpack_archive(metadata, extract_dir)
+
+            # update metadata
+            metadata = util.read_conf(metadata_yaml)
+            templates = metadata.get('templates', {})
+            templates.update(template_config)
+            metadata['templates'] = templates
+            util.yaml_dump(metadata, metadata_yaml)
+
+            # write out template files
+            for name, content in template_data.items():
+                path = os.path.join(template_dir, name)
+                util.write_file(path, content)
+
+            # store new data, mark new image as modified
+            util.flat_tar(new_metadata, extract_dir)
+            self.import_image(new_metadata, rootfs)
+            self.modified = True
+
+        finally:
+            # remove tmpfiles
+            shutil.rmtree(export_dir)
+            shutil.rmtree(extract_dir)
+
     def execute(self, *args, **kwargs):
         """
         execute command in image, modifying image
@@ -58,12 +151,12 @@ class LXDImage(base.Image):
         """
         return self.instance.push_file(local_path, remote_path)
 
-    def run_script(self, script):
+    def run_script(self, *args, **kwargs):
         """
         run script in image, modifying image
         return_value: script output
         """
-        return self.instance.run_script(script)
+        return self.instance.run_script(*args, **kwargs)
 
     def snapshot(self):
         """
@@ -71,22 +164,20 @@ class LXDImage(base.Image):
         """
         # clone current instance, start and freeze clone
         instance = self.platform.launch_container(
-            container=self.instance.name, image_desc=str(self),
-            use_desc='snapshot')
-        instance.start(wait=True, wait_time=self.config.get('timeout'))
+            self.properties, self.config, container=self.instance.name,
+            image_desc=str(self), use_desc='snapshot')
+        instance.start()
         if self.config.get('boot_clean_script'):
             instance.run_script(self.config.get('boot_clean_script'))
         instance.freeze()
         return lxd_snapshot.LXDSnapshot(
-            self.properties, self.config, self.platform, instance)
+            self.platform, self.properties, self.config, instance)
 
     def destroy(self):
         """
         clean up data associated with image
         """
-        if self._instance:
-            self._instance.destroy()
-        self.pylxd_image.delete(wait=True)
+        self.pylxd_image = None
         super(LXDImage, self).destroy()
 
 # vi: ts=4 expandtab
diff --git a/tests/cloud_tests/instances/base.py b/tests/cloud_tests/instances/base.py
index 9559d28..8b127da 100644
--- a/tests/cloud_tests/instances/base.py
+++ b/tests/cloud_tests/instances/base.py
@@ -1,8 +1,5 @@
 # This file is part of cloud-init. See LICENSE file for license information.
 
-import os
-import uuid
-
 
 class Instance(object):
     """
@@ -10,26 +7,37 @@ class Instance(object):
     """
     platform_name = None
 
-    def __init__(self, name):
+    def __init__(self, platform, name, properties, config):
         """
-        setup
+        Set up instance
+        platform: platform object
+        name: hostname of instance
+        properties: image properties
+        config: image config
         """
+        self.platform = platform
         self.name = name
+        self.properties = properties
+        self.config = config
 
-    def execute(self, command, stdin=None, stdout=None, stderr=None, env={}):
+    def execute(self, command, stdout=None, stderr=None, env={},
+                ignore_errors=False, description=None):
         """
+        Execute command in instance, recording output, error and exit code.
+        Assumes functional networking and execution as root with the
+        target filesystem being available at /.
+
         command: the command to execute as root inside the image
-        stdin, stderr, stdout: file handles
+        stdout, stderr: file handles to write output and error to
         env: environment variables
-
-        Execute assumes functional networking and execution as root with the
-        target filesystem being available at /.
+        ignore_errors: do not raise an error if the command fails
+        description: purpose of command
 
         return_value: tuple containing stdout data, stderr data, exit code
         """
         raise NotImplementedError
 
-    def read_data(self, remote_path, encode=False):
+    def read_data(self, remote_path, decode=False):
         """
         read_data from instance filesystem
         remote_path: path in instance
@@ -49,25 +57,44 @@ class Instance(object):
     def pull_file(self, remote_path, local_path):
         """
         copy file at 'remote_path', from instance to 'local_path'
+        remote_path: path on remote instance
+        local_path: path on local instance
         """
         with open(local_path, 'wb') as fp:
-            fp.write(self.read_data(remote_path), encode=True)
+            fp.write(self.read_data(remote_path))
 
     def push_file(self, local_path, remote_path):
         """
         copy file at 'local_path' to instance at 'remote_path'
+        local_path: path on local instance
+        remote_path: path on remote instance
         """
         with open(local_path, 'rb') as fp:
             self.write_data(remote_path, fp.read())
 
-    def run_script(self, script):
+    def run_script(self, script, ignore_errors=False, description=None):
         """
         run script in target and return stdout
+        script: script contents
+        ignore_errors: do not raise an error if the script returns non-zero
+        description: purpose of script
+        return_value: stdout from script
         """
-        script_path = os.path.join('/tmp', str(uuid.uuid1()))
-        self.write_data(script_path, script)
-        (out, err, exit_code) = self.execute(['/bin/bash', script_path])
-        return out
+        script_path = self.tmpfile()
+        try:
+            self.write_data(script_path, script)
+            return self.execute(['/bin/bash', script_path],
+                                ignore_errors=ignore_errors,
+                                description=description)
+        finally:
+            self.execute(['rm', script_path], ignore_errors=ignore_errors)
+
+    def tmpfile(self):
+        """
+        get a tmp file in the target
+        return_value: path to new file in target
+        """
+        return self.execute(['mktemp'])[0].strip()
 
     def console_log(self):
         """
@@ -87,7 +114,7 @@ class Instance(object):
         """
         raise NotImplementedError
 
-    def start(self, wait=True):
+    def start(self, wait=True, wait_for_cloud_init=False):
         """
         start instance
         """
@@ -99,22 +126,24 @@ class Instance(object):
         """
         pass
 
-    def _wait_for_cloud_init(self, wait_time):
+    def _wait_for_system(self, wait_for_cloud_init):
         """
         wait until system has fully booted and cloud-init has finished
-        """
-        if not wait_time:
-            return
-
-        found_msg = 'found'
-        cmd = ('for ((i=0;i<{wait};i++)); do [ -f "{file}" ] && '
-               '{{ echo "{msg}";break; }} || sleep 1; done').format(
-            file='/run/cloud-init/result.json',
-            wait=wait_time, msg=found_msg)
+        wait_time: maximum time to wait
+        return_value: None, may raise OSError if wait_time exceeded
+        """
+        time = self.config['boot_timeout']
+        system_path = self.tmpfile()
+        self.write_data(system_path, self.config['system_ready_script'])
+        tests = ['$(/bin/bash "{}")'.format(system_path)]
+        if wait_for_cloud_init:
+            cloud_path = self.tmpfile()
+            self.write_data(cloud_path, self.config['cloud_init_ready_script'])
+            tests.append('$(/bin/bash "{}")'.format(cloud_path))
+        cmd = ('for ((i=0;i<{time};i++)); do {test} && exit 0; sleep 1; '
+               'done; exit 1;').format(time=time, test=' && '.join(tests))
+        if self.run_script(cmd, ignore_errors=True)[-1] != 0:
+            raise OSError('timeout: after {}s system not started'.format(time))
 
-        (out, err, exit) = self.execute(['/bin/bash', '-c', cmd])
-        if out.strip() != found_msg:
-            raise OSError('timeout: after {}s, cloud-init has not started'
-                          .format(wait_time))
 
 # vi: ts=4 expandtab
diff --git a/tests/cloud_tests/instances/lxd.py b/tests/cloud_tests/instances/lxd.py
index f0aa121..288748c 100644
--- a/tests/cloud_tests/instances/lxd.py
+++ b/tests/cloud_tests/instances/lxd.py
@@ -1,6 +1,7 @@
 # This file is part of cloud-init. See LICENSE file for license information.
 
 from tests.cloud_tests.instances import base
+from tests.cloud_tests import LOG, util
 
 
 class LXDInstance(base.Instance):
@@ -9,41 +10,66 @@ class LXDInstance(base.Instance):
     """
     platform_name = "lxd"
 
-    def __init__(self, name, platform, pylxd_container):
+    def __init__(self, platform, name, properties, config, pylxd_container):
         """
-        setup
+        Set up instance
+        platform: platform object
+        name: hostname of instance
+        properties: image properties
+        config: image config
         """
-        self.platform = platform
         self._pylxd_container = pylxd_container
-        super(LXDInstance, self).__init__(name)
+        super(LXDInstance, self).__init__(platform, name, properties, config)
 
     @property
     def pylxd_container(self):
         self._pylxd_container.sync()
         return self._pylxd_container
 
-    def execute(self, command, stdin=None, stdout=None, stderr=None, env={}):
+    def execute(self, command, stdout=None, stderr=None, env={},
+                ignore_errors=False, description=None):
         """
+        Execute command in instance, recording output, error and exit code.
+        Assumes functional networking and execution as root with the
+        target filesystem being available at /.
+
         command: the command to execute as root inside the image
-        stdin, stderr, stdout: file handles
+        stdout, stderr: file handles to write output and error to
         env: environment variables
-
-        Execute assumes functional networking and execution as root with the
-        target filesystem being available at /.
+        ignore_errors: do not raise an error if the command fails
+        description: purpose of command
 
         return_value: tuple containing stdout data, stderr data, exit code
         """
-        # TODO: the pylxd api handler for container.execute needs to be
-        #       extended to properly pass in stdin
-        # TODO: the pylxd api handler for container.execute needs to be
-        #       extended to get the return code, for now just use 0
+        # ensure instance is running and execute the command
         self.start()
-        if stdin:
-            raise NotImplementedError
         res = self.pylxd_container.execute(command, environment=env)
-        for (f, data) in (i for i in zip((stdout, stderr), res) if i[0]):
-            f.write(data)
-        return res + (0,)
+
+        # get out, exit and err from pylxd return
+        if hasattr(res, 'exit_code'):
+            # pylxd 2.2 returns ContainerExecuteResult, named tuple of
+            # (exit_code, out, err)
+            (exit, out, err) = res
+        else:
+            # pylxd 2.1.3 and earlier only return out and err, no exit
+            LOG.warning('using pylxd version < 2.2')
+            (out, err) = res
+            exit = 0
+
+        # write data to file descriptors if needed
+        if stdout:
+            stdout.write(out)
+        if stderr:
+            stderr.write(err)
+
+        # if the command failed and ignore_errors is not set, raise an error
+        if exit != 0 and not ignore_errors:
+            error_desc = ('Failed command to: {}'.format(description)
+                          if description else None)
+            raise util.InTargetExecuteError(
+                out, err, exit, command, self.name, error_desc)
+
+        return (out, err, exit)
 
     def read_data(self, remote_path, decode=False):
         """
@@ -83,14 +109,14 @@ class LXDInstance(base.Instance):
         if self.pylxd_container.status != 'Stopped':
             self.pylxd_container.stop(wait=wait)
 
-    def start(self, wait=True, wait_time=None):
+    def start(self, wait=True, wait_for_cloud_init=False):
         """
         start instance
         """
         if self.pylxd_container.status != 'Running':
             self.pylxd_container.start(wait=wait)
-            if wait and isinstance(wait_time, int):
-                self._wait_for_cloud_init(wait_time)
+            if wait:
+                self._wait_for_system(wait_for_cloud_init)
 
     def freeze(self):
         """
diff --git a/tests/cloud_tests/platforms.yaml b/tests/cloud_tests/platforms.yaml
index 5972b32..b91834a 100644
--- a/tests/cloud_tests/platforms.yaml
+++ b/tests/cloud_tests/platforms.yaml
@@ -10,7 +10,55 @@ default_platform_config:
 platforms:
     lxd:
         enabled: true
-        get_image_timeout: 600
+        # overrides for image templates
+        template_overrides:
+            /var/lib/cloud/seed/nocloud-net/meta-data:
+                when:
+                    - create
+                    - copy
+                template: cloud-init-meta.tpl
+            /var/lib/cloud/seed/nocloud-net/network-config:
+                when:
+                    - create
+                    - copy
+                template: cloud-init-network.tpl
+            /var/lib/cloud/seed/nocloud-net/user-data:
+                when:
+                    - create
+                    - copy
+                template: cloud-init-user.tpl
+                properties:
+                    default: |
+                        #cloud-config
+                        {}
+            /var/lib/cloud/seed/nocloud-net/vendor-data:
+                when:
+                    - create
+                    - copy
+                template: cloud-init-vendor.tpl
+                properties:
+                    default: |
+                        #cloud-config
+                        {}
+        # overrides image template files
+        template_files:
+            cloud-init-meta.tpl: |
+                #cloud-config
+                instance-id: {{ container.name }}
+                local-hostname: {{ container.name }}
+                {{ config_get("user.meta-data", "") }}
+            cloud-init-network.tpl: |
+                {% if config_get("user.network-config", "") == "" %}version: 1
+                config:
+                    - type: physical
+                      name: eth0
+                      subnets:
+                          - type: {% if config_get("user.network_mode", "") == "link-local" %}manual{% else %}dhcp{% endif %}
+                            control: auto{% else %}{{ config_get("user.network-config", "") }}{% endif %}
+            cloud-init-user.tpl: |
+                {{ config_get("user.user-data", properties.default) }}
+            cloud-init-vendor.tpl: |
+                {{ config_get("user.vendor-data", properties.default) }}
     ec2: {}
     azure: {}
 
diff --git a/tests/cloud_tests/platforms/base.py b/tests/cloud_tests/platforms/base.py
index 615e2e0..2b6e514 100644
--- a/tests/cloud_tests/platforms/base.py
+++ b/tests/cloud_tests/platforms/base.py
@@ -15,17 +15,7 @@ class Platform(object):
 
     def get_image(self, img_conf):
         """
-        Get image using 'img_conf', where img_conf is a dict containing all
-        image configuration parameters
-
-        in this dict there must be a 'platform_ident' key containing
-        configuration for identifying each image on a per platform basis
-
-        see implementations for get_image() for details about the contents
-        of the platform's config entry
-
-        note: see 'releases' main_config.yaml for example entries
-
+        get image using specified image configuration
         img_conf: configuration for image
         return_value: cloud_tests.images instance
         """
@@ -37,17 +27,4 @@ class Platform(object):
         """
         pass
 
-    def _extract_img_platform_config(self, img_conf):
-        """
-        extract platform configuration for current platform from img_conf
-        """
-        platform_ident = img_conf.get('platform_ident')
-        if not platform_ident:
-            raise ValueError('invalid img_conf, missing \'platform_ident\'')
-        ident = platform_ident.get(self.platform_name)
-        if not ident:
-            raise ValueError('img_conf: {} missing config for platform {}'
-                             .format(img_conf, self.platform_name))
-        return ident
-
 # vi: ts=4 expandtab
diff --git a/tests/cloud_tests/platforms/lxd.py b/tests/cloud_tests/platforms/lxd.py
index 847cc54..82e2710 100644
--- a/tests/cloud_tests/platforms/lxd.py
+++ b/tests/cloud_tests/platforms/lxd.py
@@ -27,28 +27,30 @@ class LXDPlatform(base.Platform):
 
     def get_image(self, img_conf):
         """
-        Get image
-        img_conf: dict containing config for image. platform_ident must have:
-            alias: alias to use for simplestreams server
-            sstreams_server: simplestreams server to use, or None for default
+        get image using specified image configuration
+        img_conf: configuration for image
         return_value: cloud_tests.images instance
         """
-        lxd_conf = self._extract_img_platform_config(img_conf)
-        image = self.client.images.create_from_simplestreams(
-            lxd_conf.get('sstreams_server', DEFAULT_SSTREAMS_SERVER),
-            lxd_conf['alias'])
-        return lxd_image.LXDImage(
-            image.properties['description'], img_conf, self, image)
+        pylxd_image = self.client.images.create_from_simplestreams(
+            img_conf.get('sstreams_server', DEFAULT_SSTREAMS_SERVER),
+            img_conf['alias'])
+        image = lxd_image.LXDImage(self, img_conf, pylxd_image)
+        if img_conf.get('override_templates', False):
+            image.update_templates(self.config.get('template_overrides', {}),
+                                   self.config.get('template_files', {}))
+        return image
 
-    def launch_container(self, image=None, container=None, ephemeral=False,
-                         config=None, block=True,
+    def launch_container(self, properties, config, image=None, container=None,
+                         ephemeral=False, container_config=None, block=True,
                          image_desc=None, use_desc=None):
         """
         launch a container
+        properties: image properties
+        config: image configuration
         image: image fingerprint to launch from
         container: container to copy
         ephemeral: delete image after first shutdown
-        config: config options for instance as dict
+        container_config: config options for instance as dict
         block: wait until container created
         image_desc: description of image being launched
         use_desc: description of container's use
@@ -61,11 +63,13 @@ class LXDPlatform(base.Platform):
                                            use_desc=use_desc,
                                            used_list=self.list_containers()),
             'ephemeral': bool(ephemeral),
-            'config': config if isinstance(config, dict) else {},
+            'config': (container_config
+                       if isinstance(container_config, dict) else {}),
             'source': ({'type': 'image', 'fingerprint': image} if image else
                        {'type': 'copy', 'source': container})
         }, wait=block)
-        return lxd_instance.LXDInstance(container.name, self, container)
+        return lxd_instance.LXDInstance(self, container.name, properties,
+                                        config, container)
 
     def container_exists(self, container_name):
         """
@@ -88,6 +92,14 @@ class LXDPlatform(base.Platform):
         """
         return [container.name for container in self.client.containers.all()]
 
+    def query_image_by_alias(self, alias):
+        """
+        get image by alias in local image store
+        alias: alias of image
+        return_value: pylxd image (not cloud_tests.images instance)
+        """
+        return self.client.images.get_by_alias(alias)
+
     def destroy(self):
         """
         Clean up platform data
diff --git a/tests/cloud_tests/releases.yaml b/tests/cloud_tests/releases.yaml
index 3ffa68f..8a99e4d 100644
--- a/tests/cloud_tests/releases.yaml
+++ b/tests/cloud_tests/releases.yaml
@@ -1,79 +1,132 @@
 # ============================= Release Config ================================
 default_release_config:
-    # all are disabled by default
-    enabled: false
-    # timeout for booting image and running cloud init
-    timeout: 120
-    # platform_ident values for the image, with data to identify the image
-    # on that platform. see platforms.base for more information
-    platform_ident: {}
-    # a script to run after a boot that is used to modify an image, before
-    # making a snapshot of the image. may be useful for removing data left
-    # behind from cloud-init booting, such as logs, to ensure that data from
-    # snapshot.launch() will not include a cloud-init.log from a boot used to
-    # create the snapshot, if cloud-init has not run
-    boot_clean_script: |
-        #!/bin/bash
-        rm -rf /var/log/cloud-init.log /var/log/cloud-init-output.log \
-            /var/lib/cloud/ /run/cloud-init/ /var/log/syslog
+    # global default configuration options
+    default:
+        # all are disabled by default
+        enabled: false
+        # timeout for booting image and running cloud init
+        boot_timeout: 120
+        # a script to run after a boot that is used to modify an image, before
+        # making a snapshot of the image. may be useful for removing data left
+        # behind from cloud-init booting, such as logs, to ensure that data
+        # from snapshot.launch() will not include a cloud-init.log from a boot
+        # used to create the snapshot, if cloud-init has not run
+        boot_clean_script: |
+            #!/bin/bash
+            rm -rf /var/log/cloud-init.log /var/log/cloud-init-output.log \
+                /var/lib/cloud/ /run/cloud-init/ /var/log/syslog
+        # test script to determine if system is booted fully
+        system_ready_script: |
+            #!/bin/bash
+            # permit running or degraded state as both indicate complete boot
+            [ $(systemctl is-system-running) = 'running' -o \
+              $(systemctl is-system-running) = 'degraded' ]
+        # test script to determine if cloud-init has finished
+        cloud_init_ready_script: |
+            #!/bin/bash
+            [ -f '/run/cloud-init/result.json' ]
+
+    # lxd specific default configuration options
+    lxd:
+        # default sstreams server to use for lxd image retrieval
+        sstreams_server: https://us.images.linuxcontainers.org:8443
+        # keep base image, avoids downloading again next run
+        cache_base_image: true
+        # lxd images from linuxcontainers.org do not have the nocloud seed
+        # templates in place, so the image metadata must be modified
+        override_templates: true
+        # arg overrides to set image up
+        setup_overrides:
+            # lxd images from linuxcontainers.org do not come with
+            # cloud-init, so must pull cloud-init in from repo using
+            # setup_image.upgrade
+            upgrade: true
 
 releases:
-    trusty:
-        enabled: true
-        platform_ident:
-            lxd:
-                # if sstreams_server is omitted, default is used, defined in
-                # tests.cloud_tests.platforms.lxd.DEFAULT_SSTREAMS_SERVER as:
-                # sstreams_server: https://us.images.linuxcontainers.org:8443
-                #alias: ubuntu/trusty/default
-                alias: t
-                sstreams_server: https://cloud-images.ubuntu.com/daily
-    xenial:
-        enabled: true
-        platform_ident:
-            lxd:
-                #alias: ubuntu/xenial/default
-                alias: x
-                sstreams_server: https://cloud-images.ubuntu.com/daily
-    yakkety:
-        enabled: true
-        platform_ident:
-            lxd:
-                #alias: ubuntu/yakkety/default
-                alias: y
-                sstreams_server: https://cloud-images.ubuntu.com/daily
+    # UBUNTU =================================================================
     zesty:
-        enabled: true
-        platform_ident:
-            lxd:
-                #alias: ubuntu/zesty/default
-                alias: z
-                sstreams_server: https://cloud-images.ubuntu.com/daily
-    jessie:
-        platform_ident:
-            lxd:
-                alias: debian/jessie/default
+        # EOL: Jan 2018
+        default:
+            enabled: true
+        lxd:
+            alias: ubuntu/zesty/default
+    yakkety:
+        # EOL: Jul 2017
+        default:
+            enabled: true
+        lxd:
+            alias: ubuntu/yakkety/default
+    xenial:
+        # EOL: Apr 2021
+        default:
+            enabled: true
+        lxd:
+            alias: ubuntu/xenial/default
+    trusty:
+        # EOL: Apr 2019
+        default:
+            enabled: true
+            system_ready_script: |
+                #!/bin/bash
+                # upstart based, so use old style runlevels
+                [ $(runlevel | awk '{print $2}') = '2' ]
+        lxd:
+            alias: ubuntu/trusty/default
+    precise:
+        # EOL: Apr 2017
+        default:
+            # still supported but not relevant for development, not enabled
+            # tests should still work though unless they use newer features
+            enabled: false
+            system_ready_script: |
+                #!/bin/bash
+                # upstart based, so use old style runlevels
+                [ $(runlevel | awk '{print $2}') = '2' ]
+        lxd:
+            alias: ubuntu/precise/default
+    # DEBIAN =================================================================
     sid:
-        platform_ident:
-            lxd:
-                alias: debian/sid/default
+        # EOL: N/A
+        default:
+            # tests should work on sid, however it is not always stable
+            enabled: false
+        lxd:
+            alias: debian/sid/default
     stretch:
-        platform_ident:
-            lxd:
-                alias: debian/stretch/default
+        # EOL: Not yet released
+        default:
+            enabled: true
+        lxd:
+            alias: debian/stretch/default
+    jessie:
+        # EOL: Jun 2020
+        default:
+            enabled: true
+        lxd:
+            alias: debian/jessie/default
     wheezy:
-        platform_ident:
-            lxd:
-                alias: debian/wheezy/default
+        # EOL: May 2018 (Apr 2016 - end of full updates)
+        default:
+            # this is old enough that it is no longer relevant for development
+            enabled: false
+        lxd:
+            alias: debian/wheezy/default
+    # CENTOS =================================================================
     centos70:
-        timeout: 180
-        platform_ident:
-            lxd:
-                alias: centos/7/default
+        # EOL: Jun 2024 (2020 - end of full updates)
+        default:
+            enabled: true
+        lxd:
+            alias: centos/7/default
     centos66:
-        timeout: 180
-        platform_ident:
-            lxd:
-                alias: centos/6/default
+        # EOL: Nov 2020
+        default:
+            enabled: true
+            # still supported, but only bugfixes after may 2017
+            system_ready_script: |
+                #!/bin/bash
+                [ $(runlevel | awk '{print $2}') = '3' ]
+        lxd:
+            alias: centos/6/default
 
 # vi: ts=4 expandtab
diff --git a/tests/cloud_tests/run_funcs.py b/tests/cloud_tests/run_funcs.py
new file mode 100644
index 0000000..683a3f6
--- /dev/null
+++ b/tests/cloud_tests/run_funcs.py
@@ -0,0 +1,65 @@
+# This file is part of cloud-init. See LICENSE file for license information.
+
+from tests.cloud_tests import bddeb, collect, util, verify
+
+import os
+
+
+def tree_collect(args):
+    """
+    collect data using deb build from current tree
+    args: cmdline args
+    return_value: fail count
+    """
+    failed = 0
+
+    with util.TempDir(args) as tmpdir:
+        args.deb = os.path.join(tmpdir, 'cloud-init.deb')
+        try:
+            failed += bddeb.bddeb(args)
+            failed += collect.collect(args)
+        except Exception:
+            failed += 1
+            raise
+
+    return failed
+
+
+def tree_run(args):
+    """
+    run test suite using deb build from current tree
+    args: cmdline args
+    return_value: fail count
+    """
+    failed = 0
+
+    with util.TempDir(args) as tmpdir:
+        args.deb = os.path.join(tmpdir, 'cloud-init.deb')
+        try:
+            failed += bddeb.bddeb(args)
+            failed += run(args)
+        except Exception:
+            failed += 1
+            raise
+
+    return failed
+
+
+def run(args):
+    """
+    run test suite
+    """
+    failed = 0
+
+    with util.TempDir(args) as tmpdir:
+        args.data_dir = tmpdir
+        try:
+            failed += collect.collect(args)
+            failed += verify.verify(args)
+        except Exception:
+            failed += 1
+            raise
+
+    return failed
+
+# vi: ts=4 expandtab
diff --git a/tests/cloud_tests/setup_image.py b/tests/cloud_tests/setup_image.py
index 5d6c638..28cbfbf 100644
--- a/tests/cloud_tests/setup_image.py
+++ b/tests/cloud_tests/setup_image.py
@@ -7,6 +7,30 @@ from functools import partial
 import os
 
 
+def installed_version(image, package, ensure_installed=True):
+    """
+    get installed version of package
+    image: cloud_tests.images instance to operate on
+    package: name of package
+    ensure_installed: raise error if not installed
+    return_value: cloud-init version string
+    """
+    # get right cmd for os family
+    os_family = util.get_os_family(image.properties['os'])
+    if os_family == 'debian':
+        cmd = ['dpkg-query', '-W', "--showformat='${Version}'", package]
+    elif os_family == 'redhat':
+        cmd = ['rpm', '-q', '--queryformat', "'%{VERSION}'", package]
+    else:
+        raise NotImplementedError
+
+    # query version
+    msg = 'query version for package: {}'.format(package)
+    (out, err, exit) = image.execute(cmd, description=msg,
+                                     ignore_errors=not ensure_installed)
+    return out.strip()
+
+
 def install_deb(args, image):
     """
     install deb into image
@@ -21,20 +45,18 @@ def install_deb(args, image):
                                   'family: {}'.format(args.deb, os_family))
 
     # install deb
-    LOG.debug('installing deb: %s into target', args.deb)
+    msg = 'install deb: "{}" into target'.format(args.deb)
+    LOG.debug(msg)
     remote_path = os.path.join('/tmp', os.path.basename(args.deb))
     image.push_file(args.deb, remote_path)
-    (out, err, exit) = image.execute(['dpkg', '-i', remote_path])
-    if exit != 0:
-        raise OSError('failed install deb: {}\n\tstdout: {}\n\tstderr: {}'
-                      .format(args.deb, out, err))
+    cmd = 'dpkg -i {} || apt-get install --yes -f'.format(remote_path)
+    image.execute(['/bin/sh', '-c', cmd], description=msg)
 
     # check installed deb version matches package
     fmt = ['-W', "--showformat='${Version}'"]
     (out, err, exit) = image.execute(['dpkg-deb'] + fmt + [remote_path])
     expected_version = out.strip()
-    (out, err, exit) = image.execute(['dpkg-query'] + fmt + ['cloud-init'])
-    found_version = out.strip()
+    found_version = installed_version(image, 'cloud-init')
     if expected_version != found_version:
         raise OSError('install deb version "{}" does not match expected "{}"'
                       .format(found_version, expected_version))
@@ -52,24 +74,21 @@ def install_rpm(args, image):
     """
     # ensure system is compatible with package format
     os_family = util.get_os_family(image.properties['os'])
-    if os_family not in ['redhat', 'sles']:
+    if os_family != 'redhat':
         raise NotImplementedError('install rpm: {} not supported on os '
                                   'family: {}'.format(args.rpm, os_family))
 
     # install rpm
-    LOG.debug('installing rpm: %s into target', args.rpm)
+    msg = 'install rpm: "{}" into target'.format(args.rpm)
+    LOG.debug(msg)
     remote_path = os.path.join('/tmp', os.path.basename(args.rpm))
     image.push_file(args.rpm, remote_path)
-    (out, err, exit) = image.execute(['rpm', '-U', remote_path])
-    if exit != 0:
-        raise OSError('failed to install rpm: {}\n\tstdout: {}\n\tstderr: {}'
-                      .format(args.rpm, out, err))
+    image.execute(['rpm', '-U', remote_path], description=msg)
 
     fmt = ['--queryformat', '"%{VERSION}"']
     (out, err, exit) = image.execute(['rpm', '-q'] + fmt + [remote_path])
     expected_version = out.strip()
-    (out, err, exit) = image.execute(['rpm', '-q'] + fmt + ['cloud-init'])
-    found_version = out.strip()
+    found_version = installed_version(image, 'cloud-init')
     if expected_version != found_version:
         raise OSError('install rpm version "{}" does not match expected "{}"'
                       .format(found_version, expected_version))
@@ -80,13 +99,34 @@ def install_rpm(args, image):
 
 def upgrade(args, image):
     """
-    run the system's upgrade command
+    upgrade or install cloud-init from repo
+    args: cmdline arguments
+    image: cloud_tests.images instance to operate on
+    return_value: None, may raise errors
+    """
+    # determine command for os_family
+    os_family = util.get_os_family(image.properties['os'])
+    if os_family == 'debian':
+        cmd = 'apt-get update && apt-get install cloud-init --yes'
+    elif os_family == 'redhat':
+        cmd = 'yum install cloud-init --assumeyes'
+    else:
+        raise NotImplementedError
+
+    # upgrade cloud-init
+    msg = 'upgrading cloud-init'
+    LOG.debug(msg)
+    image.execute(['/bin/sh', '-c', cmd], description=msg)
+
+
+def upgrade_full(args, image):
+    """
+    run the system's full upgrade command
     args: cmdline arguments
     image: cloud_tests.images instance to operate on
     return_value: None, may raise errors
     """
     # determine appropriate upgrade command for os_family
-    # TODO: maybe use cloudinit.distros for this?
     os_family = util.get_os_family(image.properties['os'])
     if os_family == 'debian':
         cmd = 'apt-get update && apt-get upgrade --yes'
@@ -97,11 +137,9 @@ def upgrade(args, image):
                                   'from family: {}'.format(os_family))
 
     # upgrade system
-    LOG.debug('upgrading system')
-    (out, err, exit) = image.execute(['/bin/sh', '-c', cmd])
-    if exit != 0:
-        raise OSError('failed to upgrade system\n\tstdout: {}\n\tstderr:{}'
-                      .format(out, err))
+    msg = 'full system upgrade'
+    LOG.debug(msg)
+    image.execute(['/bin/sh', '-c', cmd], description=msg)
 
 
 def run_script(args, image):
@@ -111,9 +149,9 @@ def run_script(args, image):
     image: cloud_tests.images instance to operate on
     return_value: None, may raise errors
     """
-    # TODO: get exit status back from script and add error handling here
-    LOG.debug('running setup image script in target image')
-    image.run_script(args.script)
+    msg = 'run setup image script in target image'
+    LOG.debug(msg)
+    image.run_script(args.script, description=msg)
 
 
 def enable_ppa(args, image):
@@ -124,17 +162,15 @@ def enable_ppa(args, image):
     return_value: None, may raise errors
     """
     # ppa only supported on ubuntu (maybe debian?)
-    if image.properties['os'] != 'ubuntu':
+    if image.properties['os'].lower() != 'ubuntu':
         raise NotImplementedError('enabling a ppa is only available on ubuntu')
 
     # add ppa with add-apt-repository and update
     ppa = 'ppa:{}'.format(args.ppa)
-    LOG.debug('enabling %s', ppa)
+    msg = 'enable ppa: "{}" in target'.format(ppa)
+    LOG.debug(msg)
     cmd = 'add-apt-repository --yes {} && apt-get update'.format(ppa)
-    (out, err, exit) = image.execute(['/bin/sh', '-c', cmd])
-    if exit != 0:
-        raise OSError('enable ppa for {} failed\n\tstdout: {}\n\tstderr: {}'
-                      .format(ppa, out, err))
+    image.execute(['/bin/sh', '-c', cmd], description=msg)
 
 
 def enable_repo(args, image):
@@ -155,11 +191,9 @@ def enable_repo(args, image):
         raise NotImplementedError('enable repo command not configured for '
                                   'distro from family: {}'.format(os_family))
 
-    LOG.debug('enabling repo: "%s"', args.repo)
-    (out, err, exit) = image.execute(['/bin/sh', '-c', cmd])
-    if exit != 0:
-        raise OSError('enable repo {} failed\n\tstdout: {}\n\tstderr: {}'
-                      .format(args.repo, out, err))
+    msg = 'enable repo: "{}" in target'.format(args.repo)
+    LOG.debug(msg)
+    image.execute(['/bin/sh', '-c', cmd], description=msg)
 
 
 def setup_image(args, image):
@@ -169,6 +203,11 @@ def setup_image(args, image):
     image: cloud_tests.image instance to operate on
     return_value: tuple of results and fail count
     """
+    # update the args if necessary for this image
+    overrides = image.setup_overrides
+    LOG.debug('updating args for setup with: %s', overrides)
+    args = util.update_args(args, overrides, preserve_old=True)
+
     # mapping of setup cmdline arg name to setup function
     # represented as a tuple rather than a dict or odict as lookup by name not
     # needed, and order is important as --script and --upgrade go at the end
@@ -179,17 +218,19 @@ def setup_image(args, image):
         ('repo', enable_repo, 'setup func for --repo, enable repo'),
         ('ppa', enable_ppa, 'setup func for --ppa, enable ppa'),
         ('script', run_script, 'setup func for --script, run script'),
-        ('upgrade', upgrade, 'setup func for --upgrade, upgrade pkgs'),
+        ('upgrade', upgrade, 'setup func for --upgrade, upgrade cloud-init'),
+        ('upgrade-full', upgrade_full, 'setup func for --upgrade-full'),
     )
 
     # determine which setup functions needed
     calls = [partial(stage.run_single, desc, partial(func, args, image))
              for name, func, desc in handlers if getattr(args, name, None)]
 
-    image_name = 'image: distro={}, release={}'.format(
-        image.properties['os'], image.properties['release'])
-    LOG.info('setting up %s', image_name)
-    return stage.run_stage('set up for {}'.format(image_name), calls,
-                           continue_after_error=False)
+    LOG.info('setting up %s', image)
+    res = stage.run_stage(
+        'set up for {}'.format(image), calls, continue_after_error=False)
+    LOG.debug('after setup complete, installed cloud-init version is: %s',
+              installed_version(image, 'cloud-init'))
+    return res
 
 # vi: ts=4 expandtab
diff --git a/tests/cloud_tests/snapshots/base.py b/tests/cloud_tests/snapshots/base.py
index d715f03..e9b721e 100644
--- a/tests/cloud_tests/snapshots/base.py
+++ b/tests/cloud_tests/snapshots/base.py
@@ -7,10 +7,11 @@ class Snapshot(object):
     """
     platform_name = None
 
-    def __init__(self, properties, config):
+    def __init__(self, platform, properties, config):
         """
         Set up snapshot
         """
+        self.platform = platform
         self.properties = properties
         self.config = config
 
diff --git a/tests/cloud_tests/snapshots/lxd.py b/tests/cloud_tests/snapshots/lxd.py
index eabbce3..d2a123a 100644
--- a/tests/cloud_tests/snapshots/lxd.py
+++ b/tests/cloud_tests/snapshots/lxd.py
@@ -9,13 +9,12 @@ class LXDSnapshot(base.Snapshot):
     """
     platform_name = "lxd"
 
-    def __init__(self, properties, config, platform, pylxd_frozen_instance):
+    def __init__(self, platform, properties, config, pylxd_frozen_instance):
         """
         Set up snapshot
         """
-        self.platform = platform
         self.pylxd_frozen_instance = pylxd_frozen_instance
-        super(LXDSnapshot, self).__init__(properties, config)
+        super(LXDSnapshot, self).__init__(platform, properties, config)
 
     def launch(self, user_data, meta_data=None, block=True, start=True,
                use_desc=None):
@@ -34,10 +33,11 @@ class LXDSnapshot(base.Snapshot):
         if meta_data:
             inst_config['user.meta-data'] = meta_data
         instance = self.platform.launch_container(
-            container=self.pylxd_frozen_instance.name, config=inst_config,
-            block=block, image_desc=str(self), use_desc=use_desc)
+            self.properties, self.config, block=block, image_desc=str(self),
+            container=self.pylxd_frozen_instance.name, use_desc=use_desc,
+            container_config=inst_config)
         if start:
-            instance.start(wait=True, wait_time=self.config.get('timeout'))
+            instance.start()
         return instance
 
     def destroy(self):
diff --git a/tests/cloud_tests/util.py b/tests/cloud_tests/util.py
index 64a8667..ecbedf9 100644
--- a/tests/cloud_tests/util.py
+++ b/tests/cloud_tests/util.py
@@ -1,16 +1,26 @@
 # This file is part of cloud-init. See LICENSE file for license information.
 
+import copy
 import glob
 import os
 import random
+import shutil
 import string
 import tempfile
 import yaml
 
-from cloudinit.distros import OSFAMILIES
 from cloudinit import util as c_util
 from tests.cloud_tests import LOG
 
+OS_FAMILY_MAPPING = {
+    'debian': ['debian', 'ubuntu'],
+    'redhat': ['centos', 'rhel', 'fedora'],
+    'gentoo': ['gentoo'],
+    'freebsd': ['freebsd'],
+    'suse': ['sles'],
+    'arch': ['arch'],
+}
+
 
 def list_test_data(data_dir):
     """
@@ -68,7 +78,7 @@ def gen_instance_name(prefix='cloud-test', image_desc=None, use_desc=None,
         """
         filter bad characters out of elem and trim to length
         """
-        elem = elem[:max_len] if elem else unknown
+        elem = elem.lower()[:max_len] if elem else unknown
         return ''.join(c if c in valid else delim for c in elem)
 
     return next(name for name in
@@ -88,7 +98,9 @@ def get_os_family(os_name):
     """
     get os family type for os_name
     """
-    return next((k for k, v in OSFAMILIES.items() if os_name in v), None)
+    os_name = os_name.lower()
+    return next((k for k, v in OS_FAMILY_MAPPING.items()
+                 if os_name.lower() in v), None)
 
 
 def current_verbosity():
@@ -158,6 +170,112 @@ def write_file(*args, **kwargs):
     """
     write a file using cloudinit.util.write_file
     """
-    c_util.write_file(*args, **kwargs)
+    return c_util.write_file(*args, **kwargs)
+
+
+def read_conf(*args, **kwargs):
+    """
+    read configuration using cloudinit.util.read_conf
+    """
+    return c_util.read_conf(*args, **kwargs)
+
+
+def subp(*args, **kwargs):
+    """
+    execute a command on the system shell using cloudinit.util.subp
+    """
+    return c_util.subp(*args, **kwargs)
+
+
+def tmpdir(prefix='cloud_test_util_'):
+    return tempfile.mkdtemp(prefix=prefix)
+
+
+def rel_files(basedir):
+    """
+    list of files under directory by relative path, not including directories
+    return_value: list or relative paths
+    """
+    basedir = os.path.normpath(basedir)
+    return [path[len(basedir) + 1:] for path in
+            glob.glob(os.path.join(basedir, '**'), recursive=True)
+            if not os.path.isdir(path)]
+
+
+def flat_tar(output, basedir, owner='root', group='root'):
+    """
+    create a flat tar archive (no leading ./) from basedir
+    output: output tar file to write
+    basedir: base directory for archive
+    owner: owner of archive files
+    group: group archive files belong to
+    return_value: none
+    """
+    c_util.subp(['tar', 'cf', output, '--owner', owner, '--group', group,
+                 '-C', basedir] + rel_files(basedir), capture=True)
+
+
+def update_args(args, updates, preserve_old=True):
+    """
+    update cmdline arguments from a dictionary
+    args: cmdline arguments
+    updates: dictionary of {arg_name: new_value} mappings
+    preserve_old: if true, create a deep copy of args before updating
+    return_value: updated cmdline arguments, as new object if preserve_old=True
+    """
+    args = copy.deepcopy(args) if preserve_old else args
+    vars(args).update(updates)
+    return args
+
+
+class TempDir(object):
+    """
+    temporary directory like tempfile.TemporaryDirectory, but configurable
+    """
+
+    def __init__(self, args):
+        """
+        setup and store args
+        args: cmdline arguments
+        """
+        self.args = args
+        self.tmpdir = None
+
+    def __enter__(self):
+        """
+        create tempdir
+        return_value: tempdir path
+        """
+        self.tmpdir = tempfile.mkdtemp(prefix='cloud_test_')
+        LOG.debug('using tmpdir: %s', self.tmpdir)
+        return self.tmpdir
+
+    def __exit__(self, etype, value, trace):
+        """
+        destroy tempdir if no errors occurred
+        """
+        if etype:
+            LOG.warning('erros occurred, leaving data in %s', self.tmpdir)
+        else:
+            shutil.rmtree(self.tmpdir)
+
+
+class InTargetExecuteError(c_util.ProcessExecutionError):
+    """
+    Error type for in target commands that fail
+    """
+    default_desc = 'Unexpected error while running command in target instance'
+
+    def __init__(self, stdout, stderr, exit_code, cmd, instance,
+                 description=None):
+        """
+        init error and parent error class
+        """
+        if isinstance(cmd, (tuple, list)):
+            cmd = ' '.join(cmd)
+        super(InTargetExecuteError, self).__init__(
+            stdout=stdout, stderr=stderr, exit_code=exit_code, cmd=cmd,
+            reason="Instance: {}".format(instance),
+            description=description if description else self.default_desc)
 
 # vi: ts=4 expandtab
diff --git a/tests/cloud_tests/verify.py b/tests/cloud_tests/verify.py
index ef7d4e2..2a63550 100644
--- a/tests/cloud_tests/verify.py
+++ b/tests/cloud_tests/verify.py
@@ -45,9 +45,9 @@ def verify_data(base_dir, tests):
         }
 
         for failure in res[test_name]['failures']:
-            LOG.warn('test case: %s failed %s.%s with: %s',
-                     test_name, failure['class'], failure['function'],
-                     failure['error'])
+            LOG.warning('test case: %s failed %s.%s with: %s',
+                        test_name, failure['class'], failure['function'],
+                        failure['error'])
 
     return res
 
@@ -80,7 +80,8 @@ def verify(args):
             if len(fail_list) == 0:
                 LOG.info('test: %s passed all tests', test_name)
             else:
-                LOG.warn('test: %s failed %s tests', test_name, len(fail_list))
+                LOG.warning('test: %s failed %s tests', test_name,
+                            len(fail_list))
             failed += len(fail_list)
 
     # dump results
diff --git a/tox.ini b/tox.ini
index e79ea6a..00e5ea9 100644
--- a/tox.ini
+++ b/tox.ini
@@ -64,6 +64,27 @@ deps =
     flake8==2.5.4
     hacking==0.10.2
 
+[testenv:citest_tree_run]
+basepython = python3
+commands = {envpython} -m tests.cloud_tests tree_run -v {posargs:-n xenial}
+passenv = HOME
+deps =
+    pylxd==2.1.3
+
+[testenv:citest]
+basepython = python3
+commands = {envpython} -m tests.cloud_tests {posargs}
+passenv = HOME
+deps =
+    pylxd==2.1.3
+
+[testenv:citest_new_pylxd]
+basepython = python3
+commands = {envpython} -m tests.cloud_tests {posargs}
+passenv = HOME
+deps =
+    pylxd==2.2.2
+
 [testenv:centos6]
 basepython = python2.6
 commands = nosetests {posargs:tests}

References