← Back to team overview

cloud-init-dev team mailing list archive

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

 

Joshua Powers has proposed merging ~powersj/cloud-init:integration-testing into cloud-init:master.

Commit message:
inttests: initial commit of integration test framework

The adds in end-to-end testing of cloud-init. The framework utilizes
LXD and cloud images as a backend to test user-data passed in. 
Arbitrary data is then captured from predefined commands specified
by the user. After collection, data verification is completed by
running a series of Python unit tests against the collected data.

Currently only the Ubuntu Trusty, Xenial, Yakkety, and Zesty
releases are supported. Test cases for 50% of the modules is
complete and available. 

Additionally a Read the Docs file was created to guide test
writing and execution.


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

For more details, see:
https://code.launchpad.net/~powersj/cloud-init/+git/cloud-init/+merge/312830

inttests: initial commit of integration test framework

The adds in end-to-end testing of cloud-init. The framework utilizes
LXD and cloud images as a backend to test user-data passed in. 
Arbitrary data is then captured from predefined commands specified
by the user. After collection, data verification is completed by
running a series of Python unit tests against the collected data.

Currently only the Ubuntu Trusty, Xenial, Yakkety, and Zesty
releases are supported. Test cases for 50% of the modules is
complete and available. 

Additionally a Read the Docs file was created to guide test
writting and execution.

-- 
Your team cloud init development team is requested to review the proposed merge of ~powersj/cloud-init:integration-testing into cloud-init:master.
diff --git a/doc/rtd/index.rst b/doc/rtd/index.rst
index 3caf33f..e328627 100644
--- a/doc/rtd/index.rst
+++ b/doc/rtd/index.rst
@@ -41,6 +41,10 @@ initialization of a cloud instance.
    topics/vendordata.rst
    topics/moreinfo.rst
    topics/hacking.rst
+<<<<<<< doc/rtd/index.rst
+=======
+   topics/tests.rst
+>>>>>>> doc/rtd/index.rst
 
 .. _Cloud-init: https://launchpad.net/cloud-init
 .. vi: textwidth=78
diff --git a/doc/rtd/topics/tests.rst b/doc/rtd/topics/tests.rst
new file mode 100644
index 0000000..d2db140
--- /dev/null
+++ b/doc/rtd/topics/tests.rst
@@ -0,0 +1,238 @@
+.. contents:: Table of Contents
+   :depth: 2
+
+============================
+Test Development
+============================
+
+
+Overview
+--------
+
+The purpose of this page is to describe how to write integration tests for cloud-init. As a test writer you need to develop a test configuration and a verification file:
+
+ * The test configuration specifies a specific cloud-config to be used by cloud-init and a list of arbitrary commands to capture the output of (e.g my_test.yaml)
+
+ * The verification file runs tests on the collected output to determine the result of the test (e.g. my_test.py)
+
+The names must match, however the extensions will of course be different, yaml vs py.
+
+Configuration
+-------------
+
+The test configuration is a YAML file such as *ntp_server.yaml* below:
+
+.. code-block:: yaml
+
+    #
+    # NTP config using specific servers (ntp_server.yaml)
+    #
+    cloud_config: |
+      #cloud-config
+      ntp:
+        servers:
+          - pool.ntp.org
+    collect_scripts:
+      ntp_installed_servers: |
+        #!/bin/bash
+        dpkg -l | grep ntp | wc -l
+      ntp_conf_dist_servers: |
+        #!/bin/bash
+        ls /etc/ntp.conf.dist | wc -l
+      ntp_conf_servers: |
+        #!/bin/bash
+        cat /etc/ntp.conf | grep '^server'
+
+
+There are two keys, 1 required and 1 optional, in the YAML file:
+
+1. The required key is ``cloud_config``. This should be a string of valid YAML that is exactly what would normally be placed in a cloud-config file, including the cloud-config header. This essentially sets up the scenario under test.
+
+2. The optional key is ``collect_scripts``. This key has one or more sub-keys containing strings of arbitrary commands to execute (e.g. ```cat /var/log/cloud-config-output.log```). In the example above the output of dpkg is captured, grep for ntp, and the number of lines reported. The name of the sub-key is important. The sub-key is used by the verification script to recall the output of the commands ran.
+
+Default Collect Scripts
+~~~~~~~~~~~~~~~~~~~~~~~
+
+By default the following files will be collected for every test. There is no need to specify these items:
+
+* ``/var/log/cloud-init.log``
+* ``/var/log/cloud-init-output.log``
+* ``/run/cloud-init/.instance-id``
+* ``/run/cloud-init/result.json``
+* ``/run/cloud-init/status.json``
+* ```dpkg-query -W -f='${Version}' cloud-init```
+
+Verification
+------------
+
+The verification script is a Python file with unit tests like the one, `ntp_server.py`, below:
+
+.. code-block:: python
+
+    """cloud-init Integration Test Verify Script (ntp_server.yaml)"""
+    from tests.cloud_tests.testcases import base
+
+
+    class TestNtpServers(base.CloudTestCase):
+        """Test ntp module"""
+
+        def test_ntp_installed(self):
+            """Test ntp installed"""
+            out = self.get_data_file('ntp_installed_servers')
+            self.assertEqual(1, int(out))
+
+        def test_ntp_dist_entries(self):
+            """Test dist config file has one entry"""
+            out = self.get_data_file('ntp_conf_dist_servers')
+            self.assertEqual(1, int(out))
+
+        def test_ntp_entires(self):
+            """Test config entries"""
+            out = self.get_data_file('ntp_conf_servers')
+            self.assertIn('server pool.ntp.org iburst', out)
+
+
+Here is a breakdown of the unit test file:
+
+* The import statement allows access to the output files.
+
+* The class can be named anything, but must import the ``base.CloudTestCase``
+
+* There can be 1 to N number of functions with any name, however only tests starting with ``test_*`` will be executed.
+
+* Output from the commands can be accessed via ``self.get_data_file('key')`` where key is the sub-key of ``collect_scripts`` above.
+
+Layout
+------
+
+Integration tests are located under the `tests/cloud_tests` directory. Test configurations are placed under `configs` and the test verification scripts under `testcases`:
+
+.. code-block:: bash
+
+    cloud-init$ tree -d tests/cloud_tests/
+    tests/cloud_tests/
+    ├── configs
+    │   ├── bugs
+    │   ├── examples
+    │   ├── main
+    │   └── modules
+    └── testcases
+        ├── bugs
+        ├── examples
+        ├── main
+        └── modules
+
+The sub-folders of bugs, examples, main, and modules help organize the tests. View the README.md in each to understand in more detail each directory.
+
+
+=====================
+Development Checklist
+=====================
+
+* Configuration File
+    * Named 'your_test_here.yaml'
+    * Contains at least a valid cloud-config
+    * Optionally, commands to capture additional output
+    * Valid YAML
+    * Placed in the appropriate sub-folder in the configs directory
+* Verification File
+    * Named 'your_test_here.py'
+    * Valid unit tests validating output collected
+    * Passes pylint & pep8 checks
+    * Placed in the appropriate sub-folder in the testcsaes directory
+* Tested by running the test: 
+
+.. code-block:: bash
+
+    $ python3 -m tests.cloud_tests run -v -n <release of choice> --deb <build of cloud-init> -t tests/cloud_tests/configs/<dir>/your_test_here.yaml
+
+
+=========
+Execution
+=========
+
+Executing tests has three options:
+
+* ``run`` an alias to run both ``collect`` and ``verify``
+
+* ``collect`` deploys on the specified platform and os, patches with the requested deb or rpm, and finally collects output of the arbitrary commands.
+
+* ``verify`` given a directory of test data, run the Python unit tests on it to generate results.
+
+Run
+---
+The first example will provide a complete end-to-end run of data collection and verification. There are additional examples below explaining how to run one or the other independently.
+
+.. code-block:: bash
+
+    $ git clone https://git.launchpad.net/cloud-init
+    $ cd cloud-init
+    $ python3 -m tests.cloud_tests run -v -n trusty -n xenial --deb cloud-init_0.7.8~my_patch_all.deb
+
+The above command will do the following:
+
+* ``-v`` verbose output
+
+* ``run`` both collect output and run tests the output
+
+* ``-n trusty`` on the Ubuntu Trusty release
+
+* ``-n xenial`` on the Ubuntu Xenial release
+
+* ``--deb cloud-init_0.7.8~patch_all.deb`` use this deb as the version of cloud-init to run with
+
+For a more detailed explanation of each option see below.
+
+Collect
+-------
+
+If developing tests it may be necessary to see if cloud-config works as expected and the correct files are pulled down. In this case only a collect can be ran by running:
+
+.. code-block:: bash
+
+    $ python3 -m tests.cloud_tests collect -n xenial -d /tmp/collection --deb cloud-init_0.7.8~my_patch_all.deb 
+
+The above command will run the collection tests on xenial with the provided deb and place all results into `/tmp/collection`.
+
+Verify
+------
+
+When developing tests it is much easier to simply rerun the verify scripts without the more lengthy collect process. This can be done by running:
+
+.. code-block:: bash
+
+    $ python3 -m tests.cloud_tests verify -d /tmp/collection
+
+The above command will run the verify scripts on the data discovered in `/tmp/collection`.
+
+
+============
+Architecture
+============
+
+The following outlines the process flow during a complete end-to-end LXD-backed test.
+
+1. Configuration
+    * The back end and specific OS releases are verified as supported
+    * The test or tests that need to be run are determined either by directory or by individual yaml
+
+2. Image Creation
+    * Acquire the daily LXD image
+    * Install the specified cloud-init package
+    * Clean the image so that it does not appear to have been booted
+    * A snapshot of the image is created and reused by all tests
+
+3. Configuration
+    * For each test, the cloud-config is injected into a copy of the snapshot and booted
+    * The framework waits for ``/var/lib/cloud/instance/boot-finished`` (up to 120 seconds)
+    * All default commands are ran and output collected
+    * Any commands the user specified are executed and output collected
+
+4. Verification
+    * The default commands are checked for any failures, errors, and warnings to validate basic functionality of cloud-init completed successfully
+    * The user generated unit tests are then ran validating against the collected output
+
+5. Results
+    * If any failures were detected the test suite returns a failure
+
+
diff --git a/tests/cloud_tests/__init__.py b/tests/cloud_tests/__init__.py
new file mode 100644
index 0000000..8c1b6d1
--- /dev/null
+++ b/tests/cloud_tests/__init__.py
@@ -0,0 +1,26 @@
+import logging
+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')
+
+
+def _initialize_logging():
+    """
+    configure logging for cloud_tests
+    """
+    logger = logging.getLogger(__name__)
+    logger.setLevel(logging.DEBUG)
+    formatter = logging.Formatter(
+        '%(asctime)s - %(name)s - %(levelname)s - %(message)s')
+
+    console = logging.StreamHandler()
+    console.setLevel(logging.DEBUG)
+    console.setFormatter(formatter)
+
+    logger.addHandler(console)
+
+    return logger
+
+LOG = _initialize_logging()
diff --git a/tests/cloud_tests/__main__.py b/tests/cloud_tests/__main__.py
new file mode 100644
index 0000000..dbd4cbd
--- /dev/null
+++ b/tests/cloud_tests/__main__.py
@@ -0,0 +1,89 @@
+import argparse
+import logging
+import shutil
+import sys
+import tempfile
+
+from tests.cloud_tests import (args, collect, manage, verify)
+from tests.cloud_tests import LOG
+
+
+def configure_log(args):
+    """
+    configure logging
+    """
+    level = logging.INFO
+    if args.verbose:
+        level = logging.DEBUG
+    elif args.quiet:
+        level = logging.WARN
+    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
+    """
+    # configure parser
+    parser = argparse.ArgumentParser(prog='cloud_tests')
+    subparsers = parser.add_subparsers(dest="subcmd")
+    subparsers.required = True
+
+    def add_subparser(name, description, arg_sets):
+        """
+        add arguments to subparser
+        """
+        subparser = subparsers.add_parser(name, help=description)
+        for (_args, _kwargs) in (a for arg_set in arg_sets for a in arg_set):
+            subparser.add_argument(*_args, **_kwargs)
+
+    # configure subparsers
+    for (name, (description, arg_sets)) in args.SUBCMDS.items():
+        add_subparser(name, description,
+                      [args.ARG_SETS[arg_set] for arg_set in arg_sets])
+
+    # parse arguments
+    parsed = parser.parse_args()
+
+    # process arguments
+    configure_log(parsed)
+    (_, arg_sets) = args.SUBCMDS[parsed.subcmd]
+    for normalizer in [args.NORMALIZERS[arg_set] for arg_set in arg_sets]:
+        parsed = normalizer(parsed)
+        if not parsed:
+            return -1
+
+    # run handler
+    LOG.debug('running with args: %s\n', parsed)
+    return {
+        'collect': collect.collect,
+        'create': manage.create,
+        'run': run,
+        'verify': verify.verify,
+    }[parsed.subcmd](parsed)
+
+
+if __name__ == "__main__":
+    sys.exit(main())
diff --git a/tests/cloud_tests/args.py b/tests/cloud_tests/args.py
new file mode 100644
index 0000000..6a5c722
--- /dev/null
+++ b/tests/cloud_tests/args.py
@@ -0,0 +1,217 @@
+import os
+
+from tests.cloud_tests import config, util
+from tests.cloud_tests import LOG
+
+ARG_SETS = {
+    'COLLECT': (
+        (('-p', '--platform'),
+         {'help': 'platform(s) to run tests on', 'metavar': 'PLATFORM',
+          'action': 'append', 'choices': config.list_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(),
+          'default': []}),
+        (('-t', '--test-config'),
+         {'help': 'test config file(s) to use', 'metavar': 'FILE',
+          'action': 'append', 'default': []}),),
+    'CREATE': (
+        (('-c', '--config'),
+         {'help': 'cloud-config yaml for testcase', 'metavar': 'DATA',
+          'action': 'store', 'required': False, 'default': None}),
+        (('-e', '--enable'),
+         {'help': 'enable testcase', 'required': False, 'default': False,
+          'action': 'store_true'}),
+        (('name',),
+         {'help': 'testcase name, in format "<category>/<test>"',
+          'action': 'store'}),
+        (('-d', '--description'),
+         {'help': 'description of testcase', 'required': False}),
+        (('-f', '--force'),
+         {'help': 'overwrite already existing test', 'required': False,
+          'action': 'store_true', 'default': False}),),
+    'INTERFACE': (
+        (('-v', '--verbose'),
+         {'help': 'verbose output', 'action': 'store_true', 'default': False}),
+        (('-q', '--quiet'),
+         {'help': 'quiet output', 'action': 'store_true', 'default': False}),),
+    'OUTPUT': (
+        (('-d', '--data-dir'),
+         {'help': 'directory to store test data in',
+          'action': 'store', 'metavar': 'DIR', 'required': True}),),
+    'RESULT': (
+        (('-r', '--result'),
+         {'help': 'file to write results to',
+          'action': 'store', 'metavar': 'FILE'}),),
+    'SETUP': (
+        (('--deb',),
+         {'help': 'install deb', 'metavar': 'FILE', 'action': 'store'}),
+        (('--rpm',),
+         {'help': 'install rpm', 'metavar': 'FILE', 'action': 'store'}),
+        (('--script',),
+         {'help': 'script to set up image', 'metavar': 'DATA',
+          'action': 'store'}),
+        (('--repo',),
+         {'help': 'repo to enable (implies -u)', 'metavar': 'NAME',
+          'action': 'store'}),
+        (('--ppa',),
+         {'help': 'ppa to enable (implies -u)', 'metavar': 'NAME',
+          'action': 'store'}),
+        (('-u', '--upgrade'),
+         {'help': 'upgrade before starting tests', 'action': 'store_true',
+          'default': False}),),
+}
+
+SUBCMDS = {
+    'collect': ('collect test data',
+                ('COLLECT', 'INTERFACE', 'OUTPUT', 'RESULT', 'SETUP')),
+    'create': ('create new test case', ('CREATE', 'INTERFACE')),
+    'run': ('run test suite', ('COLLECT', 'INTERFACE', 'RESULT', 'SETUP')),
+    'verify': ('verify test data', ('INTERFACE', 'OUTPUT', 'RESULT')),
+}
+
+
+def _empty_normalizer(args):
+    """
+    do not normalize arguments
+    """
+    return args
+
+
+def normalize_create_args(args):
+    """
+    normalize CREATE arguments
+    args: parsed args
+    return_value: updated args, or None if errors occurred
+    """
+    # ensure valid name for new test
+    if len(args.name.split('/')) != 2:
+        LOG.error('invalid test name: %s', args.name)
+        return None
+    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)
+        else:
+            LOG.error(msg)
+            return None
+
+    # ensure test config valid if specified
+    if isinstance(args.config, str) and len(args.config) == 0:
+        LOG.error('test config cannot be empty if specified')
+        return None
+
+    # ensure description valid if specified
+    if (isinstance(args.description, str) and
+            (len(args.description) > 70 or len(args.description) == 0)):
+        LOG.error('test description must be between 1 and 70 characters')
+        return None
+
+    return args
+
+
+def normalize_collect_args(args):
+    """
+    normalize COLLECT arguments
+    args: parsed args
+    return_value: updated args, or None if errors occurred
+    """
+    # platform should default to all supported
+    if len(args.platform) == 0:
+        args.platform = config.list_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()
+    else:
+        supported = config.list_enabled_distros()
+        invalid = [os_name for os_name in args.os_name
+                   if os_name not in supported]
+        if len(invalid) != 0:
+            LOG.error('invalid os name(s): %s', invalid)
+            return None
+    args.os_name = util.sorted_unique(args.os_name)
+
+    # test configs should default to all enabled
+    # if test configs are provided, ensure that all provided are valid
+    if len(args.test_config) == 0:
+        args.test_config = config.list_test_configs()
+    else:
+        valid = []
+        invalid = []
+        for name in args.test_config:
+            if os.path.exists(name):
+                valid.append(name)
+            elif os.path.exists(config.name_to_path(name)):
+                valid.append(config.name_to_path(name))
+            else:
+                invalid.append(name)
+        if len(invalid) != 0:
+            LOG.error('invalid test config(s): %s', invalid)
+            return None
+        else:
+            args.test_config = valid
+    args.test_config = util.sorted_unique(args.test_config)
+
+    return args
+
+
+def normalize_output_args(args):
+    """
+    normalize OUTPUT arguments
+    args: parsed args
+    return_value: updated args, or None if errors occurred
+    """
+    if not args.data_dir:
+        LOG.error('--data-dir must be specified')
+        return None
+
+    # ensure clean output dir if collect
+    # ensure data exists if verify
+    if args.subcmd == 'collect':
+        if not util.is_clean_writable_dir(args.data_dir):
+            LOG.error('data_dir must be empty/new and must be writable')
+            return None
+    elif args.subcmd == 'verify':
+        if not os.path.exists(args.data_dir):
+            LOG.error('data_dir %s does not exist', args.data_dir)
+            return None
+
+    return args
+
+
+def normalize_setup_args(args):
+    """
+    normalize SETUP arguments
+    args: parsed args
+    return_value: updated_args, or None if errors occurred
+    """
+    # ensure deb or rpm valid if specified
+    for pkg in (args.deb, args.rpm):
+        if pkg is not None and not os.path.exists(pkg):
+            LOG.error('cannot find package: %s', pkg)
+            return None
+
+    # if repo or ppa to be enabled run upgrade
+    if args.repo or args.ppa:
+        args.upgrade = True
+
+    # if ppa is specified, remove leading 'ppa:' if any
+    _ppa_header = 'ppa:'
+    if args.ppa and args.ppa.startswith(_ppa_header):
+        args.ppa = args.ppa[len(_ppa_header):]
+
+    return args
+
+
+NORMALIZERS = {
+    'COLLECT': normalize_collect_args,
+    'CREATE': normalize_create_args,
+    'INTERFACE': _empty_normalizer,
+    'OUTPUT': normalize_output_args,
+    'RESULT': _empty_normalizer,
+    'SETUP': normalize_setup_args,
+}
diff --git a/tests/cloud_tests/collect.py b/tests/cloud_tests/collect.py
new file mode 100644
index 0000000..f2767e6
--- /dev/null
+++ b/tests/cloud_tests/collect.py
@@ -0,0 +1,157 @@
+from tests.cloud_tests import (config, LOG, setup_image, util)
+from tests.cloud_tests.stage import (PlatformComponent, run_stage, run_single)
+from tests.cloud_tests import (platforms, images, snapshots, instances)
+
+from functools import partial
+import os
+
+
+def collect_script(instance, base_dir, script, script_name):
+    """
+    collect script data
+    instance: instance to run script on
+    base_dir: base directory for output data
+    script: script contents
+    script_name: name of script to run
+    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))
+
+
+def collect_test_data(args, snapshot, os_name, test_name):
+    """
+    collect data for test case
+    args: cmdline arguments
+    snapshot: instantiated snapshot
+    test_name: name or path of test to run
+    return_value: tuple of results and fail count
+    """
+    res = ({}, 1)
+
+    # load test config
+    test_name = config.path_to_name(test_name)
+    test_config = config.load_test_config(test_name)
+    user_data = test_config['cloud_config']
+    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)
+        return ({}, 0)
+
+    # create test instance
+    component = PlatformComponent(
+        partial(instances.get_instance, snapshot, user_data,
+                block=True, start=False, use_desc=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))
+        collect_calls = [partial(run_single, 'script {}'.format(script_name),
+                                 partial(collect_script, instance,
+                                         test_output_dir, script, script_name))
+                         for script_name, script in test_scripts.items()]
+
+        res = run_stage('collect for test: {}'.format(test_name),
+                        [start_call] + collect_calls)
+
+    return res
+
+
+def collect_snapshot(args, image, os_name):
+    """
+    collect data for snapshot of image
+    args: cmdline arguments
+    image: instantiated image with set up complete
+    return_value tuple of results and fail count
+    """
+    res = ({}, 1)
+
+    component = PlatformComponent(partial(snapshots.get_snapshot, image))
+
+    LOG.debug('creating snapshot for %s', os_name)
+    with component as snapshot:
+        LOG.info('collecting test data for os: %s', os_name)
+        res = run_stage(
+            'collect test data for {}'.format(os_name),
+            [partial(collect_test_data, args, snapshot, os_name, test_name)
+             for test_name in args.test_config])
+
+    return res
+
+
+def collect_image(args, platform, os_name):
+    """
+    collect data for image
+    args: cmdline arguments
+    platform: instantiated platform
+    os_name: name of distro to collect for
+    return_value: tuple of results and fail count
+    """
+    res = ({}, 1)
+
+    os_config = config.load_os_config(os_name)
+    if not os_config.get('enabled'):
+        raise ValueError('OS {} not enabled'.format(os_name))
+
+    component = PlatformComponent(
+        partial(images.get_image, platform, os_config))
+
+    LOG.info('acquiring image for os: %s', os_name)
+    with component as image:
+        res = run_stage('set up and collect data for os: {}'.format(os_name),
+                        [partial(setup_image.setup_image, args, image)] +
+                        [partial(collect_snapshot, args, image, os_name)],
+                        continue_after_error=False)
+
+    return res
+
+
+def collect_platform(args, platform_name):
+    """
+    collect data for platform
+    args: cmdline arguments
+    platform_name: platform to collect for
+    return_value: tuple of results and fail count
+    """
+    res = ({}, 1)
+
+    platform_config = config.load_platform_config(platform_name)
+    if not platform_config.get('enabled'):
+        raise ValueError('Platform {} not enabled'.format(platform_name))
+
+    component = PlatformComponent(
+        partial(platforms.get_platform, platform_name, platform_config))
+
+    LOG.info('setting up platform: %s', platform_name)
+    with component as platform:
+        res = run_stage('collect for platform: {}'.format(platform_name),
+                        [partial(collect_image, args, platform, os_name)
+                         for os_name in args.os_name])
+
+    return res
+
+
+def collect(args):
+    """
+    entry point for collection
+    args: cmdline arguments
+    return_value: fail count
+    """
+    (res, failed) = run_stage(
+        'collect data', [partial(collect_platform, args, platform_name)
+                         for platform_name in args.platform])
+
+    LOG.debug('collect stages: %s', res)
+    if args.result:
+        util.merge_results({'collect_stages': res}, args.result)
+
+    return failed
diff --git a/tests/cloud_tests/config.py b/tests/cloud_tests/config.py
new file mode 100644
index 0000000..d37eb75
--- /dev/null
+++ b/tests/cloud_tests/config.py
@@ -0,0 +1,109 @@
+import glob
+import os
+
+from cloudinit import util as c_util
+from tests.cloud_tests import (BASE_DIR, TEST_CONF_DIR)
+
+# conf files
+CONF_EXT = '.yaml'
+VERIFY_EXT = '.py'
+PLATFORM_CONF = os.path.join(BASE_DIR, 'platforms.yaml')
+RELEASES_CONF = os.path.join(BASE_DIR, 'releases.yaml')
+TESTCASE_CONF = os.path.join(BASE_DIR, 'testcases.yaml')
+
+
+def path_to_name(path):
+    """
+    convert abs or rel path to test config to path under configs/
+    if already a test name, do nothing
+    """
+    dir_path, file_name = os.path.split(os.path.normpath(path))
+    name = os.path.splitext(file_name)[0]
+    return os.sep.join((os.path.basename(dir_path), name))
+
+
+def name_to_path(name):
+    """
+    convert test config path under configs/ to full config path,
+    if already a full path, do nothing
+    """
+    name = os.path.normpath(name)
+    if not name.endswith(CONF_EXT):
+        name = name + CONF_EXT
+    return name if os.path.isabs(name) else os.path.join(TEST_CONF_DIR, name)
+
+
+def name_sanatize(name):
+    """
+    sanatize test name to be used as a module name
+    """
+    return name.replace('-', '_')
+
+
+def name_to_module(name):
+    """
+    convert test name to a loadable module name under testcases/
+    """
+    name = name_sanatize(path_to_name(name))
+    return name.replace(os.path.sep, '.')
+
+
+def merge_config(base, override):
+    """
+    merge config and base
+    """
+    res = base.copy()
+    res.update(override)
+    res.update({k: merge_config(base.get(k, {}), v)
+                for k, v in override.items() if isinstance(v, dict)})
+    return res
+
+
+def load_platform_config(platform):
+    """
+    load configuration for platform
+    """
+    main_conf = c_util.read_conf(PLATFORM_CONF)
+    return merge_config(main_conf.get('default_platform_config'),
+                        main_conf.get('platforms')[platform])
+
+
+def load_os_config(os_name):
+    """
+    load configuration for os
+    """
+    main_conf = c_util.read_conf(RELEASES_CONF)
+    return merge_config(main_conf.get('default_release_config'),
+                        main_conf.get('releases')[os_name])
+
+
+def load_test_config(path):
+    """
+    load a test config file by either abs path or rel path
+    """
+    return merge_config(c_util.read_conf(TESTCASE_CONF)['base_test_data'],
+                        c_util.read_conf(name_to_path(path)))
+
+
+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')]
+
+
+def list_enabled_distros():
+    """
+    list all distros enabled for testing
+    """
+    releases = c_util.read_conf(RELEASES_CONF).get('releases')
+    return [k for k, v in releases.items() if v.get('enabled')]
+
+
+def list_test_configs():
+    """
+    list all available test config files by abspath
+    """
+    return [os.path.abspath(f) for f in
+            glob.glob(os.sep.join((TEST_CONF_DIR, '*', '*.yaml')))]
diff --git a/tests/cloud_tests/configs/bugs/README.md b/tests/cloud_tests/configs/bugs/README.md
new file mode 100644
index 0000000..269059a
--- /dev/null
+++ b/tests/cloud_tests/configs/bugs/README.md
@@ -0,0 +1,11 @@
+# Bug Test Configs
+
+## purpose
+Configs that reproduce bugs filed against cloud-init. Having test configs for
+cloud-init bugs ensures that the fixes do not break in the future, and makes it
+easy to see how many systems and platforms are effected by a new bug.
+
+## structure
+Should have one test config for most bugs filed. The name of the test should
+contain ``lp`` followed by the bug number. It may also be useful to add a
+comment to each bug config with a summary copied from the bug report.
diff --git a/tests/cloud_tests/configs/bugs/lp1511485.yaml b/tests/cloud_tests/configs/bugs/lp1511485.yaml
new file mode 100644
index 0000000..846162f
--- /dev/null
+++ b/tests/cloud_tests/configs/bugs/lp1511485.yaml
@@ -0,0 +1,9 @@
+#
+# LP Bug 1511485: final_message is silent on ubuntu-12.04.5 / cloud-init 0.6.3
+#
+# 2016-11-17: Disabled as covered by module based tests
+#
+enabled: False
+cloud_config: |
+  #cloud-config
+  final_message: "Final message from cloud-config"
diff --git a/tests/cloud_tests/configs/bugs/lp1611074.yaml b/tests/cloud_tests/configs/bugs/lp1611074.yaml
new file mode 100644
index 0000000..94005e8
--- /dev/null
+++ b/tests/cloud_tests/configs/bugs/lp1611074.yaml
@@ -0,0 +1,6 @@
+#
+# LP Bug 1611074: Reformatting of ephemeral drive fails on resize of Azure VM
+#
+# 2016-11-18: Disabled until test written
+#
+enabled: False
diff --git a/tests/cloud_tests/configs/bugs/lp1628337.yaml b/tests/cloud_tests/configs/bugs/lp1628337.yaml
new file mode 100644
index 0000000..251662a
--- /dev/null
+++ b/tests/cloud_tests/configs/bugs/lp1628337.yaml
@@ -0,0 +1,18 @@
+#
+# LP Bug 1628337: cloud-init tries to install NTP before even configuring the archives
+#
+cloud_config: |
+  #cloud-config
+  ntp:
+    servers: ['ntp.ubuntu.com']
+  apt:
+    primary:
+      - arches: [default]
+        uri: http://us.archive.ubuntu.com/ubuntu/
+collect_sciprts:
+  ntp.conf: |
+    #!/bin/bash
+    cat /etc/ntp.conf
+  sources.list: |
+    #!/bin/bash
+    cat /etc/apt/sources.list
diff --git a/tests/cloud_tests/configs/examples/README.md b/tests/cloud_tests/configs/examples/README.md
new file mode 100644
index 0000000..73b8fce
--- /dev/null
+++ b/tests/cloud_tests/configs/examples/README.md
@@ -0,0 +1,10 @@
+# Example Test Configs
+
+## Purpose
+This folder contains example cloud configs found on
+[cloudinit.readthedocs.io](https://cloudinit.readthedocs.io/en/latest/topics/examples.html).
+Examples covered by other tests, like modules, are excluded from tests here
+to prevent duplication and reduce test time.
+
+## Structure
+One test per example test config on cloudinit.readthedocs.io
diff --git a/tests/cloud_tests/configs/examples/TODO.md b/tests/cloud_tests/configs/examples/TODO.md
new file mode 100644
index 0000000..66404cb
--- /dev/null
+++ b/tests/cloud_tests/configs/examples/TODO.md
@@ -0,0 +1,13 @@
+# Missing Examples
+
+Below lists each of the issing examples and why it is not currently added.
+
+ - Chef (takes > 60 seconds to run)
+ - Puppet (takes > 60 seconds to run)
+ - Manage resolve.conf (lxd backend overrides changes)
+ - Adding a yum repository (need centos system)
+ - Register RedHat Subscription (need centos system + subscription)
+ - Adjust mount points mounted (need multiple disks)
+ - Call a url when finished (need end point)
+ - Reboot/poweroff when finished (how to test)
+ - Disk setup (need multiple disks)
diff --git a/tests/cloud_tests/configs/examples/add_apt_repositories.yaml b/tests/cloud_tests/configs/examples/add_apt_repositories.yaml
new file mode 100644
index 0000000..91ca1a6
--- /dev/null
+++ b/tests/cloud_tests/configs/examples/add_apt_repositories.yaml
@@ -0,0 +1,19 @@
+#
+# From cloud config examples on cloudinit.readthedocs.io
+#
+# 2016-11-17: Disabled as covered by module based tests
+#
+enabled: False
+cloud_config: |
+  #cloud-config
+  apt:
+    primary:
+      - arches: [default]
+        uri: "http://www.gtlib.gatech.edu/pub/ubuntu-releases/";
+collect_scripts:
+  ubuntu.sources.list: |
+    #!/bin/bash
+    cat /etc/apt/sources.list | grep -v '^#' | sed '/^\s*$/d' | grep archive.ubuntu.com | wc -l
+  gatech.sources.list: |
+    #!/bin/bash
+    cat /etc/apt/sources.list | grep -v '^#' | sed '/^\s*$/d' | grep gtlib.gatech.edu | wc -l
diff --git a/tests/cloud_tests/configs/examples/alter_completion_message.yaml b/tests/cloud_tests/configs/examples/alter_completion_message.yaml
new file mode 100644
index 0000000..a5aa44b
--- /dev/null
+++ b/tests/cloud_tests/configs/examples/alter_completion_message.yaml
@@ -0,0 +1,14 @@
+#
+# From cloud config examples on cloudinit.readthedocs.io
+#
+# 2016-11-17: Disabled as covered by module based tests
+#
+enabled: False
+cloud_config: |
+  #cloud-config
+  final_message: |
+    This is my final message!
+    $version
+    $timestamp
+    $datasource
+    $uptime
diff --git a/tests/cloud_tests/configs/examples/configure_instance_trusted_ca_certificates.yaml b/tests/cloud_tests/configs/examples/configure_instance_trusted_ca_certificates.yaml
new file mode 100644
index 0000000..d0f7794
--- /dev/null
+++ b/tests/cloud_tests/configs/examples/configure_instance_trusted_ca_certificates.yaml
@@ -0,0 +1,39 @@
+#
+# From cloud config examples on cloudinit.readthedocs.io
+#
+# 2016-11-17: Disabled as covered by module based tests
+#
+enabled: False
+cloud_config: |
+  #cloud-config
+    ca-certs:
+      # If present and set to True, the 'remove-defaults' parameter will remove
+      # all the default trusted CA certificates that are normally shipped with
+      # Ubuntu.
+      # This is mainly for paranoid admins - most users will not need this
+      # functionality.
+      remove-defaults: true
+
+      # If present, the 'trusted' parameter should contain a certificate (or list
+      # of certificates) to add to the system as trusted CA certificates.
+      # Pay close attention to the YAML multiline list syntax.  The example shown
+      # here is for a list of multiline certificates.
+      trusted:
+      - |
+       -----BEGIN CERTIFICATE-----
+       YOUR-ORGS-TRUSTED-CA-CERT-HERE
+       -----END CERTIFICATE-----
+      - |
+       -----BEGIN CERTIFICATE-----
+       YOUR-ORGS-TRUSTED-CA-CERT-HERE
+       -----END CERTIFICATE-----
+collect_scripts:
+  cloudinit_certs: |
+    #!/bin/bash
+    cat /etc/ssl/certs/cloud-init-ca-certs.pem
+  cert_count_ca: |
+    #!/bin/bash
+    wc -l /etc/ssl/certs/ca-certificates.crt
+  cert_count_cloudinit: |
+    #!/bin/bash
+    wc -l /etc/ssl/certs/cloud-init-ca-certs.pem
diff --git a/tests/cloud_tests/configs/examples/configure_instances_ssh_keys.yaml b/tests/cloud_tests/configs/examples/configure_instances_ssh_keys.yaml
new file mode 100644
index 0000000..c2f03be
--- /dev/null
+++ b/tests/cloud_tests/configs/examples/configure_instances_ssh_keys.yaml
@@ -0,0 +1,61 @@
+#
+# From cloud config examples on cloudinit.readthedocs.io
+#
+# 2016-11-17: Disabled as covered by module based tests
+#
+enabled: False
+cloud_config: |
+  #cloud-config
+  ssh_authorized_keys:
+    - ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAGEA3FSyQwBI6Z+nCSjUUk8EEAnnkhXlukKoUPND/RRClWz2s5TCzIkd3Ou5+Cyz71X0XmazM3l5WgeErvtIwQMyT1KjNoMhoJMrJnWqQPOt5Q8zWd9qG7PBl9+eiH5qV7NZ mykey@host
+    - ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEA3I7VUf2l5gSn5uavROsc5HRDpZdQueUq5ozemNSj8T7enqKHOEaFoU2VoPgGEWC9RyzSQVeyD6s7APMcE82EtmW4skVEgEGSbDc1pvxzxtchBj78hJP6Cf5TCMFSXw+Fz5rF1dR23QDbN1mkHs7adr8GW4kSWqU7Q7NDwfIrJJtO7Hi42GyXtvEONHbiRPOe8stqUly7MvUoN+5kfjBM8Qqpfl2+FNhTYWpMfYdPUnE7u536WqzFmsaqJctz3gBxH9Ex7dFtrxR4qiqEr9Qtlu3xGn7Bw07/+i1D+ey3ONkZLN+LQ714cgj8fRS4Hj29SCmXp5Kt5/82cD/VN3NtHw== smoser@brickies
+
+  # Send pre-generated ssh private keys to the server
+  # If these are present, they will be written to /etc/ssh and
+  # new random keys will not be generated
+  #  in addition to 'rsa' and 'dsa' as shown below, 'ecdsa' is also supported
+  ssh_keys:
+    rsa_private: |
+      -----BEGIN RSA PRIVATE KEY-----
+      MIIBxwIBAAJhAKD0YSHy73nUgysO13XsJmd4fHiFyQ+00R7VVu2iV9Qcon2LZS/x
+      1cydPZ4pQpfjEha6WxZ6o8ci/Ea/w0n+0HGPwaxlEG2Z9inNtj3pgFrYcRztfECb
+      1j6HCibZbAzYtwIBIwJgO8h72WjcmvcpZ8OvHSvTwAguO2TkR6mPgHsgSaKy6GJo
+      PUJnaZRWuba/HX0KGyhz19nPzLpzG5f0fYahlMJAyc13FV7K6kMBPXTRR6FxgHEg
+      L0MPC7cdqAwOVNcPY6A7AjEA1bNaIjOzFN2sfZX0j7OMhQuc4zP7r80zaGc5oy6W
+      p58hRAncFKEvnEq2CeL3vtuZAjEAwNBHpbNsBYTRPCHM7rZuG/iBtwp8Rxhc9I5w
+      ixvzMgi+HpGLWzUIBS+P/XhekIjPAjA285rVmEP+DR255Ls65QbgYhJmTzIXQ2T9
+      luLvcmFBC6l35Uc4gTgg4ALsmXLn71MCMGMpSWspEvuGInayTCL+vEjmNBT+FAdO
+      W7D4zCpI43jRS9U06JVOeSc9CDk2lwiA3wIwCTB/6uc8Cq85D9YqpM10FuHjKpnP
+      REPPOyrAspdeOAV+6VKRavstea7+2DZmSUgE
+      -----END RSA PRIVATE KEY-----
+
+    rsa_public: ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAGEAoPRhIfLvedSDKw7XdewmZ3h8eIXJD7TRHtVW7aJX1ByifYtlL/HVzJ09nilCl+MSFrpbFnqjxyL8Rr/DSf7QcY/BrGUQbZn2Kc22PemAWthxHO18QJvWPocKJtlsDNi3 smoser@localhost
+
+    dsa_private: |
+      -----BEGIN DSA PRIVATE KEY-----
+      MIIBuwIBAAKBgQDP2HLu7pTExL89USyM0264RCyWX/CMLmukxX0Jdbm29ax8FBJT
+      pLrO8TIXVY5rPAJm1dTHnpuyJhOvU9G7M8tPUABtzSJh4GVSHlwaCfycwcpLv9TX
+      DgWIpSj+6EiHCyaRlB1/CBp9RiaB+10QcFbm+lapuET+/Au6vSDp9IRtlQIVAIMR
+      8KucvUYbOEI+yv+5LW9u3z/BAoGBAI0q6JP+JvJmwZFaeCMMVxXUbqiSko/P1lsa
+      LNNBHZ5/8MOUIm8rB2FC6ziidfueJpqTMqeQmSAlEBCwnwreUnGfRrKoJpyPNENY
+      d15MG6N5J+z81sEcHFeprryZ+D3Ge9VjPq3Tf3NhKKwCDQ0240aPezbnjPeFm4mH
+      bYxxcZ9GAoGAXmLIFSQgiAPu459rCKxT46tHJtM0QfnNiEnQLbFluefZ/yiI4DI3
+      8UzTCOXLhUA7ybmZha+D/csj15Y9/BNFuO7unzVhikCQV9DTeXX46pG4s1o23JKC
+      /QaYWNMZ7kTRv+wWow9MhGiVdML4ZN4XnifuO5krqAybngIy66PMEoQCFEIsKKWv
+      99iziAH0KBMVbxy03Trz
+      -----END DSA PRIVATE KEY-----
+
+    dsa_public: ssh-dsa AAAAB3NzaC1kc3MAAACBAM/Ycu7ulMTEvz1RLIzTbrhELJZf8Iwua6TFfQl1ubb1rHwUElOkus7xMhdVjms8AmbV1Meem7ImE69T0bszy09QAG3NImHgZVIeXBoJ/JzByku/1NcOBYilKP7oSIcLJpGUHX8IGn1GJoH7XRBwVub6Vqm4RP78C7q9IOn0hG2VAAAAFQCDEfCrnL1GGzhCPsr/uS1vbt8/wQAAAIEAjSrok/4m8mbBkVp4IwxXFdRuqJKSj8/WWxos00Ednn/ww5QibysHYULrOKJ1+54mmpMyp5CZICUQELCfCt5ScZ9GsqgmnI80Q1h3Xkwbo3kn7PzWwRwcV6muvJn4PcZ71WM+rdN/c2EorAINDTbjRo97NueM94WbiYdtjHFxn0YAAACAXmLIFSQgiAPu459rCKxT46tHJtM0QfnNiEnQLbFluefZ/yiI4DI38UzTCOXLhUA7ybmZha+D/csj15Y9/BNFuO7unzVhikCQV9DTeXX46pG4s1o23JKC/QaYWNMZ7kTRv+wWow9MhGiVdML4ZN4XnifuO5krqAybngIy66PMEoQ= smoser@localhost
+collect_scripts:
+  cert_count: |
+    #!/bin/bash
+    ls | wc -l
+  dsa_public: |
+    #!/bin/bash
+    cat /etc/ssh/ssh_host_dsa_key.pub
+  rsa_public: |
+    #!/bin/bash
+    cat /etc/ssh/ssh_host_rsa_key.pub
+  auth_keys: |
+    #!/bin/bash
+    cat /home/ubuntu/.ssh/authorized_keys
diff --git a/tests/cloud_tests/configs/examples/including_user_groups.yaml b/tests/cloud_tests/configs/examples/including_user_groups.yaml
new file mode 100644
index 0000000..67e4fea
--- /dev/null
+++ b/tests/cloud_tests/configs/examples/including_user_groups.yaml
@@ -0,0 +1,51 @@
+#
+# From cloud config examples on cloudinit.readthedocs.io
+#
+# 2016-11-17: Disabled as covered by module based tests
+#
+enabled: False
+cloud_config: |
+  #cloud-config
+  # Add groups to the system
+  groups:
+    - secret: [foobar,barfoo]
+    - cloud-users
+
+  # Add users to the system. Users are added after groups are added.
+  users:
+    - default
+    - name: foobar
+      gecos: Foo B. Bar
+      primary-group: foobar
+      groups: users
+      expiredate: 2038-01-19
+      lock_passwd: false
+      passwd: $6$j212wezy$7H/1LT4f9/N3wpgNunhsIqtMj62OKiS3nyNwuizouQc3u7MbYCarYeAHWYPYb2FT.lbioDm2RrkJPb9BZMN1O/
+    - name: barfoo
+      gecos: Bar B. Foo
+      sudo: ALL=(ALL) NOPASSWD:ALL
+      groups: cloud-users
+      lock_passwd: true
+    - name: cloudy
+      gecos: Magic Cloud App Daemon User
+      inactive: true
+      system: true
+collect_scripts:
+  group_ubuntu: |
+    #!/bin/bash
+    getent group ubuntu
+  group_cloud_users: |
+    #!/bin/bash
+    getent group cloud-users
+  user_ubuntu: |
+    #!/bin/bash
+    getent passwd ubuntu
+  user_foobar: |
+    #!/bin/bash
+    getent passwd foobar
+  user_barfoo: |
+    #!/bin/bash
+    getent passwd barfoo
+  user_cloudy: |
+    #!/bin/bash
+    getent passwd cloudy
diff --git a/tests/cloud_tests/configs/examples/install_arbitrary_packages.yaml b/tests/cloud_tests/configs/examples/install_arbitrary_packages.yaml
new file mode 100644
index 0000000..65ed649
--- /dev/null
+++ b/tests/cloud_tests/configs/examples/install_arbitrary_packages.yaml
@@ -0,0 +1,18 @@
+#
+# From cloud config examples on cloudinit.readthedocs.io
+#
+# 2016-11-17: Disabled as covered by module based tests
+#
+enabled: False
+cloud_config: |
+  #cloud-config
+  packages:
+   - htop
+   - tree
+collect_scripts:
+  htop: |
+    #!/bin/bash
+    dpkg -l | grep htop | wc -l
+  tree: |
+    #!/bin/bash
+    dpkg -l | grep tree | wc -l
diff --git a/tests/cloud_tests/configs/examples/install_run_chef_recipes.yaml b/tests/cloud_tests/configs/examples/install_run_chef_recipes.yaml
new file mode 100644
index 0000000..7b1622b
--- /dev/null
+++ b/tests/cloud_tests/configs/examples/install_run_chef_recipes.yaml
@@ -0,0 +1,92 @@
+#
+# From cloud config examples on cloudinit.readthedocs.io
+#
+# 2016-11-17: Disabled as test suite fails this long running test currently
+#
+enabled: False
+cloud_config: |
+  #cloud-config
+  # Key from http://apt.opscode.com/packages@xxxxxxxxxxxxxxxxxxx
+  apt:
+    sources:
+     - source: "deb http://apt.opscode.com/ $RELEASE-0.10 main"
+       key: |
+         -----BEGIN PGP PUBLIC KEY BLOCK-----
+         Version: GnuPG v1.4.9 (GNU/Linux)
+
+         mQGiBEppC7QRBADfsOkZU6KZK+YmKw4wev5mjKJEkVGlus+NxW8wItX5sGa6kdUu
+         twAyj7Yr92rF+ICFEP3gGU6+lGo0Nve7KxkN/1W7/m3G4zuk+ccIKmjp8KS3qn99
+         dxy64vcji9jIllVa+XXOGIp0G8GEaj7mbkixL/bMeGfdMlv8Gf2XPpp9vwCgn/GC
+         JKacfnw7MpLKUHOYSlb//JsEAJqao3ViNfav83jJKEkD8cf59Y8xKia5OpZqTK5W
+         ShVnNWS3U5IVQk10ZDH97Qn/YrK387H4CyhLE9mxPXs/ul18ioiaars/q2MEKU2I
+         XKfV21eMLO9LYd6Ny/Kqj8o5WQK2J6+NAhSwvthZcIEphcFignIuobP+B5wNFQpe
+         DbKfA/0WvN2OwFeWRcmmd3Hz7nHTpcnSF+4QX6yHRF/5BgxkG6IqBIACQbzPn6Hm
+         sMtm/SVf11izmDqSsQptCrOZILfLX/mE+YOl+CwWSHhl+YsFts1WOuh1EhQD26aO
+         Z84HuHV5HFRWjDLw9LriltBVQcXbpfSrRP5bdr7Wh8vhqJTPjrQnT3BzY29kZSBQ
+         YWNrYWdlcyA8cGFja2FnZXNAb3BzY29kZS5jb20+iGAEExECACAFAkppC7QCGwMG
+         CwkIBwMCBBUCCAMEFgIDAQIeAQIXgAAKCRApQKupg++Caj8sAKCOXmdG36gWji/K
+         +o+XtBfvdMnFYQCfTCEWxRy2BnzLoBBFCjDSK6sJqCu5Ag0ESmkLtBAIAIO2SwlR
+         lU5i6gTOp42RHWW7/pmW78CwUqJnYqnXROrt3h9F9xrsGkH0Fh1FRtsnncgzIhvh
+         DLQnRHnkXm0ws0jV0PF74ttoUT6BLAUsFi2SPP1zYNJ9H9fhhK/pjijtAcQwdgxu
+         wwNJ5xCEscBZCjhSRXm0d30bK1o49Cow8ZIbHtnXVP41c9QWOzX/LaGZsKQZnaMx
+         EzDk8dyyctR2f03vRSVyTFGgdpUcpbr9eTFVgikCa6ODEBv+0BnCH6yGTXwBid9g
+         w0o1e/2DviKUWCC+AlAUOubLmOIGFBuI4UR+rux9affbHcLIOTiKQXv79lW3P7W8
+         AAfniSQKfPWXrrcAAwUH/2XBqD4Uxhbs25HDUUiM/m6Gnlj6EsStg8n0nMggLhuN
+         QmPfoNByMPUqvA7sULyfr6xCYzbzRNxABHSpf85FzGQ29RF4xsA4vOOU8RDIYQ9X
+         Q8NqqR6pydprRFqWe47hsAN7BoYuhWqTtOLSBmnAnzTR5pURoqcquWYiiEavZixJ
+         3ZRAq/HMGioJEtMFrvsZjGXuzef7f0ytfR1zYeLVWnL9Bd32CueBlI7dhYwkFe+V
+         Ep5jWOCj02C1wHcwt+uIRDJV6TdtbIiBYAdOMPk15+VBdweBXwMuYXr76+A7VeDL
+         zIhi7tKFo6WiwjKZq0dzctsJJjtIfr4K4vbiD9Ojg1iISQQYEQIACQUCSmkLtAIb
+         DAAKCRApQKupg++CauISAJ9CxYPOKhOxalBnVTLeNUkAHGg2gACeIsbobtaD4ZHG
+         0GLl8EkfA8uhluM=
+         =zKAm
+         -----END PGP PUBLIC KEY BLOCK-----
+
+  chef:
+
+   # Valid values are 'gems' and 'packages' and 'omnibus'
+   install_type: "packages"
+
+   # Boolean: run 'install_type' code even if chef-client
+   #          appears already installed.
+   force_install: false
+
+   # Chef settings
+   server_url: "https://chef.yourorg.com:4000";
+
+   # Node Name
+   # Defaults to the instance-id if not present
+   node_name: "your-node-name"
+
+   # Environment
+   # Defaults to '_default' if not present
+   environment: "production"
+
+   # Default validation name is chef-validator
+   validation_name: "yourorg-validator"
+   # if validation_cert's value is "system" then it is expected
+   # that the file already exists on the system.
+   validation_cert: |
+       -----BEGIN RSA PRIVATE KEY-----
+       YOUR-ORGS-VALIDATION-KEY-HERE
+       -----END RSA PRIVATE KEY-----
+
+   # A run list for a first boot json
+   run_list:
+    - "recipe[apache2]"
+    - "role[db]"
+
+   # Specify a list of initial attributes used by the cookbooks
+   initial_attributes:
+      apache:
+        prefork:
+          maxclients: 100
+        keepalive: "off"
+
+   # if install_type is 'omnibus', change the url to download
+   omnibus_url: "https://www.opscode.com/chef/install.sh";
+
+
+  # Capture all subprocess output into a logfile
+  # Useful for troubleshooting cloud-init issues
+  output: {all: '| tee -a /var/log/cloud-init-output.log'}
diff --git a/tests/cloud_tests/configs/examples/run_apt_upgrade.yaml b/tests/cloud_tests/configs/examples/run_apt_upgrade.yaml
new file mode 100644
index 0000000..b7aa53f
--- /dev/null
+++ b/tests/cloud_tests/configs/examples/run_apt_upgrade.yaml
@@ -0,0 +1,9 @@
+#
+# From cloud config examples on cloudinit.readthedocs.io
+#
+# 2016-11-17: Disabled as covered by module based tests
+#
+enabled: False
+cloud_config: |
+  #cloud-config
+  package_upgrade: true
diff --git a/tests/cloud_tests/configs/examples/run_commands.yaml b/tests/cloud_tests/configs/examples/run_commands.yaml
new file mode 100644
index 0000000..7305048
--- /dev/null
+++ b/tests/cloud_tests/configs/examples/run_commands.yaml
@@ -0,0 +1,14 @@
+#
+# From cloud config examples on cloudinit.readthedocs.io
+#
+# 2016-11-17: Disabled as covered by module based tests
+#
+enabled: False
+cloud_config: |
+  #cloud-config
+  runcmd:
+   - echo cloud-init run cmd test > /tmp/run_cmd
+collect_scripts:
+  run_cmd: |
+    #!/bin/bash
+    cat /tmp/run_cmd
diff --git a/tests/cloud_tests/configs/examples/run_commands_first_boot.yaml b/tests/cloud_tests/configs/examples/run_commands_first_boot.yaml
new file mode 100644
index 0000000..8a8467e
--- /dev/null
+++ b/tests/cloud_tests/configs/examples/run_commands_first_boot.yaml
@@ -0,0 +1,14 @@
+#
+# From cloud config examples on cloudinit.readthedocs.io
+#
+# 2016-11-17: Disabled as covered by module based tests
+#
+enabled: False
+cloud_config: |
+  #cloud-config
+  bootcmd:
+   - echo 192.168.1.130 us.archive.ubuntu.com > /etc/hosts
+collect_scripts:
+  hosts: |
+    #!/bin/bash
+    cat /etc/hosts
diff --git a/tests/cloud_tests/configs/examples/setup_run_puppet.yaml b/tests/cloud_tests/configs/examples/setup_run_puppet.yaml
new file mode 100644
index 0000000..abd2177
--- /dev/null
+++ b/tests/cloud_tests/configs/examples/setup_run_puppet.yaml
@@ -0,0 +1,53 @@
+#
+# From cloud config examples on cloudinit.readthedocs.io
+#
+# 2016-11-17: Disabled as test suite fails this long running test currently
+#
+enabled: False
+cloud_config: |
+  #cloud-config
+  puppet:
+   # Every key present in the conf object will be added to puppet.conf:
+   # [name]
+   # subkey=value
+   #
+   # For example the configuration below will have the following section
+   # added to puppet.conf:
+   # [puppetd]
+   # server=puppetmaster.example.org
+   # certname=i-0123456.ip-X-Y-Z.cloud.internal
+   #
+   # The puppmaster ca certificate will be available in
+   # /var/lib/puppet/ssl/certs/ca.pem
+   conf:
+     agent:
+       server: "puppetmaster.example.org"
+       # certname supports substitutions at runtime:
+       #   %i: instanceid
+       #       Example: i-0123456
+       #   %f: fqdn of the machine
+       #       Example: ip-X-Y-Z.cloud.internal
+       #
+       # NB: the certname will automatically be lowercased as required by puppet
+       certname: "%i.%f"
+     # ca_cert is a special case. It won't be added to puppet.conf.
+     # It holds the puppetmaster certificate in pem format.
+     # It should be a multi-line string (using the | yaml notation for
+     # multi-line strings).
+     # The puppetmaster certificate is located in
+     # /var/lib/puppet/ssl/ca/ca_crt.pem on the puppetmaster host.
+     #
+     ca_cert: |
+       -----BEGIN CERTIFICATE-----
+       MIICCTCCAXKgAwIBAgIBATANBgkqhkiG9w0BAQUFADANMQswCQYDVQQDDAJjYTAe
+       Fw0xMDAyMTUxNzI5MjFaFw0xNTAyMTQxNzI5MjFaMA0xCzAJBgNVBAMMAmNhMIGf
+       MA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCu7Q40sm47/E1Pf+r8AYb/V/FWGPgc
+       b014OmNoX7dgCxTDvps/h8Vw555PdAFsW5+QhsGr31IJNI3kSYprFQcYf7A8tNWu
+       1MASW2CfaEiOEi9F1R3R4Qlz4ix+iNoHiUDTjazw/tZwEdxaQXQVLwgTGRwVa+aA
+       qbutJKi93MILLwIDAQABo3kwdzA4BglghkgBhvhCAQ0EKxYpUHVwcGV0IFJ1Ynkv
+       T3BlblNTTCBHZW5lcmF0ZWQgQ2VydGlmaWNhdGUwDwYDVR0TAQH/BAUwAwEB/zAd
+       BgNVHQ4EFgQUu4+jHB+GYE5Vxo+ol1OAhevspjAwCwYDVR0PBAQDAgEGMA0GCSqG
+       SIb3DQEBBQUAA4GBAH/rxlUIjwNb3n7TXJcDJ6MMHUlwjr03BDJXKb34Ulndkpaf
+       +GAlzPXWa7bO908M9I8RnPfvtKnteLbvgTK+h+zX1XCty+S2EQWk29i2AdoqOTxb
+       hppiGMp0tT5Havu4aceCXiy2crVcudj3NFciy8X66SoECemW9UYDCb9T5D0d
+       -----END CERTIFICATE-----
diff --git a/tests/cloud_tests/configs/examples/writing_out_arbitrary_files.yaml b/tests/cloud_tests/configs/examples/writing_out_arbitrary_files.yaml
new file mode 100644
index 0000000..37c5fb8
--- /dev/null
+++ b/tests/cloud_tests/configs/examples/writing_out_arbitrary_files.yaml
@@ -0,0 +1,43 @@
+#
+# From cloud config examples on cloudinit.readthedocs.io
+#
+# 2016-11-17: Disabled as covered by module based tests
+#
+enabled: False
+cloud_config: |
+  #cloud-config
+  write_files:
+  -   encoding: b64
+      content: CiMgVGhpcyBmaWxlIGNvbnRyb2xzIHRoZSBzdGF0ZSBvZiBTRUxpbnV4
+      owner: root:root
+      path: /root/file_b64
+      permissions: '0644'
+  -   content: |
+          # My new /root/file_text
+
+          SMBDOPTIONS="-D"
+      path: /root/file_text
+  -   content: !!binary |
+          f0VMRgIBAQAAAAAAAAAAAAIAPgABAAAAwARAAAAAAABAAAAAAAAAAJAVAAAAAAAAAAAAAEAAOAAI
+          AEAAHgAdAAYAAAAFAAAAQAAAAAAAAABAAEAAAAAAAEAAQAAAAAAAwAEAAAAAAADAAQAAAAAAAAgA
+          AAAAAAAAAwAAAAQAAAAAAgAAAAAAAAACQAAAAAAAAAJAAAAAAAAcAAAAAAAAABwAAAAAAAAAAQAA
+      path: /root/file_binary
+      permissions: '0555'
+  -   encoding: gzip
+      content: !!binary |
+          H4sIAIDb/U8C/1NW1E/KzNMvzuBKTc7IV8hIzcnJVyjPL8pJ4QIA6N+MVxsAAAA=
+      path: /root/file_gzip
+      permissions: '0755'
+collect_scripts:
+  file_b64: |
+    #!/bin/bash
+    file /root/file_b64
+  file_text: |
+    #!/bin/bash
+    file /root/file_text
+  file_binary: |
+    #!/bin/bash
+    file /root/file_binary
+  file_gzip: |
+    #!/bin/bash
+    file /root/file_gzip
diff --git a/tests/cloud_tests/configs/main/README.md b/tests/cloud_tests/configs/main/README.md
new file mode 100644
index 0000000..1418e7f
--- /dev/null
+++ b/tests/cloud_tests/configs/main/README.md
@@ -0,0 +1,9 @@
+# Main Functionality Test Configs
+
+## purpose
+Test main features and config options of cloud-init such as logging, output
+redirection, early init and integration with init system
+
+## structure
+Should have one or more test configs for all main cloud-init output and logging
+options, and basic functionality test cases
diff --git a/tests/cloud_tests/configs/main/command_output_simple.yaml b/tests/cloud_tests/configs/main/command_output_simple.yaml
new file mode 100644
index 0000000..e418ac4
--- /dev/null
+++ b/tests/cloud_tests/configs/main/command_output_simple.yaml
@@ -0,0 +1,11 @@
+#
+# Test functionality of simple output redirection
+#
+cloud_config: |
+    #cloud-config
+    output: { all: "| tee -a /var/log/cloud-init-test-output" }
+    final_message: "should be last line in cloud-init-test-output file"
+collect_scripts:
+    cloud-init-test-output: |
+        #!/bin/bash
+        cat /var/log/cloud-init-test-output
diff --git a/tests/cloud_tests/configs/modules/README.md b/tests/cloud_tests/configs/modules/README.md
new file mode 100644
index 0000000..91d2964
--- /dev/null
+++ b/tests/cloud_tests/configs/modules/README.md
@@ -0,0 +1,10 @@
+# Module Test Configs
+
+## Purpose
+Test functionality of cloud config modules. See
+[here](https://cloudinit.readthedocs.io/en/latest/topics/modules.html) for
+a full list.
+
+## Structure
+Should have one or more test configs for each module in cloudinit/config/. The
+name of the test should indicate which module the config is verifying.
diff --git a/tests/cloud_tests/configs/modules/TODO.md b/tests/cloud_tests/configs/modules/TODO.md
new file mode 100644
index 0000000..8a9e162
--- /dev/null
+++ b/tests/cloud_tests/configs/modules/TODO.md
@@ -0,0 +1,98 @@
+# TODO
+
+The following lists complete or partially misisng modules. If a module is
+listed with nothing below it indicates that no work is completed on that
+module. If there is a list below the module name that is the remainig
+identified work.
+
+## apt_configure
+
+  * apt_get_wrapper
+    * What does this do? How to use it?
+  * apt_get_command
+    * To specify a different 'apt-get' command, set 'apt_get_command'.
+    This must be a list, and the subcommand (update, upgrade) is appended to it.
+    * Modify default and verify the options got passed correctly.
+  * preserve sources
+    * TBD
+
+## chef
+2016-11-17: Tests took > 60 seconds and test framework times out currently.
+
+## disable EC2 metadata
+
+## disk setup
+
+## emit upstart
+
+## fan
+
+## growpart
+
+## grub dpkg
+
+## landscape
+2016-11-17: Module is not working
+
+## lxd
+2016-11-17: Need a zfs backed test written
+
+## mcollective
+
+## migrator
+
+## mounts
+
+## phone home
+
+## power state change
+
+## puppet
+2016-11-17: Tests took > 60 seconds and test framework times out currently.
+
+## resizefs
+
+## resolv conf
+2016-11-17: Issues with changing resolv.conf and lxc backend.
+
+## redhat subscription
+2016-11-17: Need RH support in test framework.
+
+## rightscale userdata
+2016-11-17: Specific to RightScale cloud enviornment.
+
+## rsyslog
+
+## scripts per boot
+Not applicable to write a test for this as it specifies when something should be run.
+
+## scripts per instance
+Not applicable to write a test for this as it specifies when something should be run.
+
+## scripts per once
+Not applicable to write a test for this as it specifies when something should be run.
+
+## scripts user
+Not applicable to write a test for this as it specifies when something should be run.
+
+## scripts vendor
+Not applicable to write a test for this as it specifies when something should be run.
+
+## snappy
+2016-11-17: Need test to install snaps from store
+
+## snap-config
+2016-11-17: Need to investigate
+
+## spacewalk
+
+## ssh authkey fingerprints
+The authkey_hash key does not appear to work. In fact the default claims to be md5, however syslog only shows sha256
+
+## ubuntu init switch
+
+## update etc hosts
+2016-11-17: Issues with changing /etc/hosts and lxc backend.
+
+## yum add repo
+2016-11-17: Need RH support in test framework.
diff --git a/tests/cloud_tests/configs/modules/apt_configure_conf.yaml b/tests/cloud_tests/configs/modules/apt_configure_conf.yaml
new file mode 100644
index 0000000..222c24b
--- /dev/null
+++ b/tests/cloud_tests/configs/modules/apt_configure_conf.yaml
@@ -0,0 +1,17 @@
+#
+# Provide a configuration for APT
+#
+cloud_config: |
+  #cloud-config
+  apt:
+    conf: |
+      APT {
+          Get {
+              Assume-Yes "true";
+              Fix-Broken "true";
+          }
+      }
+collect_scripts:
+  94cloud-init-config: |
+    #!/bin/bash
+    cat /etc/apt/apt.conf.d/94cloud-init-config
diff --git a/tests/cloud_tests/configs/modules/apt_configure_disable_suites.yaml b/tests/cloud_tests/configs/modules/apt_configure_disable_suites.yaml
new file mode 100644
index 0000000..45186ee
--- /dev/null
+++ b/tests/cloud_tests/configs/modules/apt_configure_disable_suites.yaml
@@ -0,0 +1,15 @@
+#
+# Disables everything in sources.list
+#
+cloud_config: |
+  #cloud-config
+  apt:
+    disable_suites:
+      - $RELEASE
+      - $RELEASE-updates
+      - $RELEASE-backports
+      - $RELEASE-security
+collect_scripts:
+  sources.list: |
+    #!/bin/bash
+    cat /etc/apt/sources.list | grep -v '^#' | sed '/^\s*$/d'
\ No newline at end of file
diff --git a/tests/cloud_tests/configs/modules/apt_configure_primary.yaml b/tests/cloud_tests/configs/modules/apt_configure_primary.yaml
new file mode 100644
index 0000000..d0548eb
--- /dev/null
+++ b/tests/cloud_tests/configs/modules/apt_configure_primary.yaml
@@ -0,0 +1,17 @@
+#
+# Setup a custome primary sources.list
+#
+cloud_config: |
+  #cloud-config
+  apt:
+    primary:
+      - arches:
+          - default
+        uri: "http://www.gtlib.gatech.edu/pub/ubuntu-releases/";
+collect_scripts:
+  ubuntu.sources.list: |
+    #!/bin/bash
+    cat /etc/apt/sources.list | grep -v '^#' | sed '/^\s*$/d' | grep archive.ubuntu.com | wc -l
+  gatech.sources.list: |
+    #!/bin/bash
+    cat /etc/apt/sources.list | grep -v '^#' | sed '/^\s*$/d' | grep gtlib.gatech.edu | wc -l
\ No newline at end of file
diff --git a/tests/cloud_tests/configs/modules/apt_configure_proxy.yaml b/tests/cloud_tests/configs/modules/apt_configure_proxy.yaml
new file mode 100644
index 0000000..e73778f
--- /dev/null
+++ b/tests/cloud_tests/configs/modules/apt_configure_proxy.yaml
@@ -0,0 +1,14 @@
+#
+# Set apt proxy
+#
+cloud_config: |
+  #cloud-config
+  apt:
+    proxy: "http://squid.internal:3128";
+    http_proxy: "http://squid.internal:3128";
+    ftp_proxy: "ftp://squid.internal:3128";
+    https_proxy: "https://squid.internal:3128";
+collect_scripts:
+  90cloud-init-aptproxy: |
+    #!/bin/bash
+    cat /etc/apt/apt.conf.d/90cloud-init-aptproxy
diff --git a/tests/cloud_tests/configs/modules/apt_configure_security.yaml b/tests/cloud_tests/configs/modules/apt_configure_security.yaml
new file mode 100644
index 0000000..582bcd5
--- /dev/null
+++ b/tests/cloud_tests/configs/modules/apt_configure_security.yaml
@@ -0,0 +1,13 @@
+#
+# Add security to sources.list
+#
+cloud_config: |
+  #cloud-config
+  apt:
+    security:
+      - arches:
+        - default
+collect_scripts:
+  sources.list: |
+    #!/bin/bash
+    cat /etc/apt/sources.list | grep security.ubuntu.com | wc -l
diff --git a/tests/cloud_tests/configs/modules/apt_configure_sources_key.yaml b/tests/cloud_tests/configs/modules/apt_configure_sources_key.yaml
new file mode 100644
index 0000000..3a72d19
--- /dev/null
+++ b/tests/cloud_tests/configs/modules/apt_configure_sources_key.yaml
@@ -0,0 +1,44 @@
+#
+# Add a sources.list entry with a given key (Debian Jessie)
+#
+cloud_config: |
+  #cloud-config
+  apt:
+    sources:
+      source1:
+        source: "deb http://ppa.launchpad.net/powersj/debootstrap-1636583/ubuntu xenial main"
+        key: |
+          -----BEGIN PGP PUBLIC KEY BLOCK-----
+          Version: SKS 1.1.5
+          Comment: Hostname: keyserver.ubuntu.com
+
+          mQINBFgP31MBEACmCDMPpfCeQBD4fcHGLTfspPEIY4QU9LNnbo5Vf3yRKIfvHMyI0ELzZNKV
+          w4JFe3Ji9TUuY4c0XxCn01kquxOs0cVppVMPKl9aB7zl1lzYUobfMMibOAIJzVSfiG477UFu
+          IeDCwnmD/JvbL4x6FEmgV4C4O4ULFQLjYXEGEbUx5HHW9j2pWgA14gLCB9euOq4hAo3Y3Hhj
+          EKSaGsR1VOQR1Y43xfbm2KyCyzbbP0x6+ob76MTX8bqxhwGzwDfOwCYHD6AVtAEMk2nOP85m
+          zULTHRLZCdvG5X+b17ojbIXsXA9qIzfzxelwJF44+QM5VkuUES5mN1yixtplp5a3+QNQZYEA
+          +LjlUvYtJKzMlP7y/BSb/SrtvWpa9+1Nl9BPE5pvZ2IGPu1nrJusdb85IPa9i5EwgB5mJOve
+          6PlZHzkQm3Lqv0V0Kh4dGU101+26lUwbqhX84SLzX0CY8/1pDJ4zSSj0L7n7PuW7fAHmBgFv
+          Gyg8WmBZodzMGn4Brkhp/12XKRqkAv4lMt7iu9MI+U/GMJ2V3vN+qtaR2BNQygJ55lmVWdCn
+          fqLMoXOn/qltkEgEA5XFNnw1ZMlDtIRs1QLHfdLIiYKbKIT1wOlhGrYi1ZgzNnWFI0KhvoXN
+          //HV6QCzf5KqVKYi6jp5Z/hgOylbyPGwcm/iXcBL9ZLZa4nL3wARAQABtB9MYXVuY2hwYWQg
+          UFBBIGZvciBKb3NodWEgUG93ZXJziQI4BBMBAgAiBQJYD99TAhsDBgsJCAcDAgYVCAIJCgsE
+          FgIDAQIeAQIXgAAKCRAiV08GEMsYiqquEACPDTRE6FBgF9QU4a4HRz4dNYlN83dZPDFyIrir
+          vlSgvcycyWh67S5Q5wx6QeEtsap9jJzqRVmKohN8Ma14M0Cgs1jEjD5fpkcWGBEr8O+pNb9z
+          o3vRfWhF6YZHai8mF7cKLC/NRCDQErLfuFU9vZg51z9QqUDBlVr87Y5ZdRieThcDjzTv3kjg
+          oRvsAIAEdvw3sJjeUSREazLVnLtSPX40LLQQuGghGTni/C6fZXTgpS0EhADOKQPfqx6CHngU
+          x/eQhnP9iqOdjTjftgt+VW89BgbOfFFew6toBeYNs8/M5Acwa01II5RM388pgX+haK8YZxmw
+          tQNnIV6qjxVY58Kv2L69XAv2FLGfTJEiT6sj7rpnCK+V8ZZq7yH9bilyVZG+gnB7nhB4B1Yk
+          iWW7mBjGvo3HNkfqt3Z9tOiWSVSZpNcAIHQozEW/YUhKY9i61J9zk4+jOyR4kjBHQ6dzb24T
+          PZYYlQIdMDWFPGHqYziHzTrYvR7RtJIwrfcd2FjWwya5hRDpS8MHsUCJ5DWvUhglITcrQ5tY
+          HK7i+n58uFpt9fsgwJGjdZu+YMmzdd26QiZR3g2UAkq70cgb/83LDpyz3oHtrqzVg60eroWc
+          QJHgeSDDGMPsTYHoCRN0G1eO3QzfLOMga2VMSLonLfulVJEwEZPp0XSiDTsvKBNVofXqRQ==
+          =BNJU
+          -----END PGP PUBLIC KEY BLOCK-----
+collect_scripts:
+  sources.list: |
+    #!/bin/bash
+    cat /etc/apt/sources.list.d/source1.list
+  apt_key_list: |
+    #!/bin/bash
+    apt-key finger
diff --git a/tests/cloud_tests/configs/modules/apt_configure_sources_keyserver.yaml b/tests/cloud_tests/configs/modules/apt_configure_sources_keyserver.yaml
new file mode 100644
index 0000000..4e22a99
--- /dev/null
+++ b/tests/cloud_tests/configs/modules/apt_configure_sources_keyserver.yaml
@@ -0,0 +1,18 @@
+#
+# Add a sources.list entry with a key from a keyserver
+#
+cloud_config: |
+  #cloud-config
+  apt:
+    sources:
+      source1:
+        keyid: 10CB188A
+        keyserver: keyserver.ubuntu.com
+        source: "deb http://ppa.launchpad.net/powersj/debootstrap-1636583/ubuntu xenial main"
+collect_scripts:
+  sources.list: |
+    #!/bin/bash
+    cat /etc/apt/sources.list.d/source1.list
+  apt_key_list: |
+    #!/bin/bash
+    apt-key finger
diff --git a/tests/cloud_tests/configs/modules/apt_configure_sources_list.yaml b/tests/cloud_tests/configs/modules/apt_configure_sources_list.yaml
new file mode 100644
index 0000000..94a2101
--- /dev/null
+++ b/tests/cloud_tests/configs/modules/apt_configure_sources_list.yaml
@@ -0,0 +1,17 @@
+#
+# Generate a sources.list
+#
+cloud_config: |
+  #cloud-config
+  apt:
+    sources_list: |
+      deb $MIRROR $RELEASE main restricted
+      deb-src $MIRROR $RELEASE main restricted
+      deb $PRIMARY $RELEASE universe restricted
+      deb-src $PRIMARY $RELEASE universe restricted
+      deb $SECURITY $RELEASE-security multiverse
+      deb-src $SECURITY $RELEASE-security multiverse
+collect_scripts:
+  sources.list: |
+    #/bin/bash
+    cat /etc/apt/sources.list
diff --git a/tests/cloud_tests/configs/modules/apt_configure_sources_ppa.yaml b/tests/cloud_tests/configs/modules/apt_configure_sources_ppa.yaml
new file mode 100644
index 0000000..65b1ab5
--- /dev/null
+++ b/tests/cloud_tests/configs/modules/apt_configure_sources_ppa.yaml
@@ -0,0 +1,18 @@
+#
+# Add a PPA to source.list
+#
+cloud_config: |
+  #cloud-config
+  apt:
+    sources:
+      source1:
+        keyid: 10CB188A
+        keyserver: keyserver.ubuntu.com
+        source: "ppa:powersj/debootstrap-1636583"
+collect_scripts:
+  sources.list: |
+    #!/bin/bash
+    cat /etc/apt/sources.list.d/powersj-ubuntu-debootstrap-1636583-*.list
+  apt-key: |
+    #!/bin/bash
+    apt-key finger
diff --git a/tests/cloud_tests/configs/modules/apt_pipelining_disable.yaml b/tests/cloud_tests/configs/modules/apt_pipelining_disable.yaml
new file mode 100644
index 0000000..25545ab
--- /dev/null
+++ b/tests/cloud_tests/configs/modules/apt_pipelining_disable.yaml
@@ -0,0 +1,11 @@
+#
+# Disable apt pipelining value
+#
+cloud_config: |
+  #cloud-config
+  apt:
+    apt_pipelining: false
+collect_scripts:
+  90cloud-init-pipelining: |
+    #!/bin/bash
+    cat /etc/apt/apt.conf.d/90cloud-init-pipelining
diff --git a/tests/cloud_tests/configs/modules/apt_pipelining_os.yaml b/tests/cloud_tests/configs/modules/apt_pipelining_os.yaml
new file mode 100644
index 0000000..16ed3cb
--- /dev/null
+++ b/tests/cloud_tests/configs/modules/apt_pipelining_os.yaml
@@ -0,0 +1,11 @@
+#
+# Set apt pipelining value to OS
+#
+cloud_config: |
+  #cloud-config
+  apt:
+    apt_pipelining: os
+collect_scripts:
+  90cloud-init-pipelining: |
+    #!/bin/bash
+    cat /etc/apt/apt.conf.d/90cloud-init-pipelining
diff --git a/tests/cloud_tests/configs/modules/bootcmd.yaml b/tests/cloud_tests/configs/modules/bootcmd.yaml
new file mode 100644
index 0000000..4767d75
--- /dev/null
+++ b/tests/cloud_tests/configs/modules/bootcmd.yaml
@@ -0,0 +1,11 @@
+#
+# Early boot command
+#
+cloud_config: |
+  #cloud-config
+  bootcmd:
+   - echo 192.168.1.130 us.archive.ubuntu.com > /etc/hosts
+collect_scripts:
+  hosts: |
+    #!/bin/bash
+    cat /etc/hosts
diff --git a/tests/cloud_tests/configs/modules/byobu.yaml b/tests/cloud_tests/configs/modules/byobu.yaml
new file mode 100644
index 0000000..6f9e4f8
--- /dev/null
+++ b/tests/cloud_tests/configs/modules/byobu.yaml
@@ -0,0 +1,16 @@
+#
+# Install and enable byobu system wide and default user
+#
+cloud_config: |
+  #cloud-config
+  byobu_by_default: enable
+collect_scripts:
+  byobu_installed: |
+    #!/bin/bash
+    which byobu
+  byobu_profile_enabled: |
+    #!/bin/bash
+    ls /etc/profile.d/Z97-byobu.sh
+  byobu_launch_exists: |
+    #!/bin/bash
+    which /usr/bin/byobu-launch
diff --git a/tests/cloud_tests/configs/modules/ca_certs.yaml b/tests/cloud_tests/configs/modules/ca_certs.yaml
new file mode 100644
index 0000000..5e386ae
--- /dev/null
+++ b/tests/cloud_tests/configs/modules/ca_certs.yaml
@@ -0,0 +1,51 @@
+#
+# Remove existing ca_certs and install custom ca-cert
+#
+cloud_config: |
+  #cloud-config
+  ca-certs:
+    remove-defaults: true
+    trusted:
+      - |
+        -----BEGIN CERTIFICATE-----
+        MIIGJzCCBA+gAwIBAgIBATANBgkqhkiG9w0BAQUFADCBsjELMAkGA1UEBhMCRlIx
+        DzANBgNVBAgMBkFsc2FjZTETMBEGA1UEBwwKU3RyYXNib3VyZzEYMBYGA1UECgwP
+        d3d3LmZyZWVsYW4ub3JnMRAwDgYDVQQLDAdmcmVlbGFuMS0wKwYDVQQDDCRGcmVl
+        bGFuIFNhbXBsZSBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkxIjAgBgkqhkiG9w0BCQEW
+        E2NvbnRhY3RAZnJlZWxhbi5vcmcwHhcNMTIwNDI3MTAzMTE4WhcNMjIwNDI1MTAz
+        MTE4WjB+MQswCQYDVQQGEwJGUjEPMA0GA1UECAwGQWxzYWNlMRgwFgYDVQQKDA93
+        d3cuZnJlZWxhbi5vcmcxEDAOBgNVBAsMB2ZyZWVsYW4xDjAMBgNVBAMMBWFsaWNl
+        MSIwIAYJKoZIhvcNAQkBFhNjb250YWN0QGZyZWVsYW4ub3JnMIICIjANBgkqhkiG
+        9w0BAQEFAAOCAg8AMIICCgKCAgEA3W29+ID6194bH6ejLrIC4hb2Ugo8v6ZC+Mrc
+        k2dNYMNPjcOKABvxxEtBamnSaeU/IY7FC/giN622LEtV/3oDcrua0+yWuVafyxmZ
+        yTKUb4/GUgafRQPf/eiX9urWurtIK7XgNGFNUjYPq4dSJQPPhwCHE/LKAykWnZBX
+        RrX0Dq4XyApNku0IpjIjEXH+8ixE12wH8wt7DEvdO7T3N3CfUbaITl1qBX+Nm2Z6
+        q4Ag/u5rl8NJfXg71ZmXA3XOj7zFvpyapRIZcPmkvZYn7SMCp8dXyXHPdpSiIWL2
+        uB3KiO4JrUYvt2GzLBUThp+lNSZaZ/Q3yOaAAUkOx+1h08285Pi+P8lO+H2Xic4S
+        vMq1xtLg2bNoPC5KnbRfuFPuUD2/3dSiiragJ6uYDLOyWJDivKGt/72OVTEPAL9o
+        6T2pGZrwbQuiFGrGTMZOvWMSpQtNl+tCCXlT4mWqJDRwuMGrI4DnnGzt3IKqNwS4
+        Qyo9KqjMIPwnXZAmWPm3FOKe4sFwc5fpawKO01JZewDsYTDxVj+cwXwFxbE2yBiF
+        z2FAHwfopwaH35p3C6lkcgP2k/zgAlnBluzACUI+MKJ/G0gv/uAhj1OHJQ3L6kn1
+        SpvQ41/ueBjlunExqQSYD7GtZ1Kg8uOcq2r+WISE3Qc9MpQFFkUVllmgWGwYDuN3
+        Zsez95kCAwEAAaN7MHkwCQYDVR0TBAIwADAsBglghkgBhvhCAQ0EHxYdT3BlblNT
+        TCBHZW5lcmF0ZWQgQ2VydGlmaWNhdGUwHQYDVR0OBBYEFFlfyRO6G8y5qEFKikl5
+        ajb2fT7XMB8GA1UdIwQYMBaAFCNsLT0+KV14uGw+quK7Lh5sh/JTMA0GCSqGSIb3
+        DQEBBQUAA4ICAQAT5wJFPqervbja5+90iKxi1d0QVtVGB+z6aoAMuWK+qgi0vgvr
+        mu9ot2lvTSCSnRhjeiP0SIdqFMORmBtOCFk/kYDp9M/91b+vS+S9eAlxrNCB5VOf
+        PqxEPp/wv1rBcE4GBO/c6HcFon3F+oBYCsUQbZDKSSZxhDm3mj7pb67FNbZbJIzJ
+        70HDsRe2O04oiTx+h6g6pW3cOQMgIAvFgKN5Ex727K4230B0NIdGkzuj4KSML0NM
+        slSAcXZ41OoSKNjy44BVEZv0ZdxTDrRM4EwJtNyggFzmtTuV02nkUj1bYYYC5f0L
+        ADr6s0XMyaNk8twlWYlYDZ5uKDpVRVBfiGcq0uJIzIvemhuTrofh8pBQQNkPRDFT
+        Rq1iTo1Ihhl3/Fl1kXk1WR3jTjNb4jHX7lIoXwpwp767HAPKGhjQ9cFbnHMEtkro
+        RlJYdtRq5mccDtwT0GFyoJLLBZdHHMHJz0F9H7FNk2tTQQMhK5MVYwg+LIaee586
+        CQVqfbscp7evlgjLW98H+5zylRHAgoH2G79aHljNKMp9BOuq6SnEglEsiWGVtu2l
+        hnx8SB3sVJZHeer8f/UQQwqbAO+Kdy70NmbSaqaVtp8jOxLiidWkwSyRTsuU6D8i
+        DiH5uEqBXExjrj0FslxcVKdVj5glVcSmkLwZKbEU1OKwleT/iXFhvooWhQ==
+        -----END CERTIFICATE-----
+collect_scripts:
+  cert_count: |
+    #!/bin/bash
+    ls -l /etc/ssl/certs | wc -l
+  cert: |
+    #!/bin/bash
+    md5sum /etc/ssl/certs/ca-certificates.crt
\ No newline at end of file
diff --git a/tests/cloud_tests/configs/modules/debug_disable.yaml b/tests/cloud_tests/configs/modules/debug_disable.yaml
new file mode 100644
index 0000000..cf94910
--- /dev/null
+++ b/tests/cloud_tests/configs/modules/debug_disable.yaml
@@ -0,0 +1,7 @@
+#
+# Do not run in debug mode
+#
+cloud_config: |
+  #cloud-config
+  debug:
+    verbose: False
diff --git a/tests/cloud_tests/configs/modules/debug_enable.yaml b/tests/cloud_tests/configs/modules/debug_enable.yaml
new file mode 100644
index 0000000..ba8080f
--- /dev/null
+++ b/tests/cloud_tests/configs/modules/debug_enable.yaml
@@ -0,0 +1,7 @@
+#
+# Run in debug mode
+#
+cloud_config: |
+  #cloud-config
+  debug:
+    verbose: True
diff --git a/tests/cloud_tests/configs/modules/final_message.yaml b/tests/cloud_tests/configs/modules/final_message.yaml
new file mode 100644
index 0000000..d2a760e
--- /dev/null
+++ b/tests/cloud_tests/configs/modules/final_message.yaml
@@ -0,0 +1,11 @@
+#
+# Print a final message with various predefined variables
+#
+cloud_config: |
+  #cloud-config
+  final_message: |
+    This is my final message!
+    $version
+    $timestamp
+    $datasource
+    $uptime
diff --git a/tests/cloud_tests/configs/modules/keys_to_console.yaml b/tests/cloud_tests/configs/modules/keys_to_console.yaml
new file mode 100644
index 0000000..504a127
--- /dev/null
+++ b/tests/cloud_tests/configs/modules/keys_to_console.yaml
@@ -0,0 +1,11 @@
+#
+# Hide printing of ssh key and fingerprints for specific keys
+#
+cloud_config: |
+  #cloud-config
+  ssh_fp_console_blacklist: [ssh-dss, ssh-dsa, ecdsa-sha2-nistp256]
+  ssh_key_console_blacklist: [ssh-dss, ssh-dsa, ecdsa-sha2-nistp256]
+collect_scripts:
+  syslog: |
+    #!/bin/bash
+    cat /var/log/syslog
diff --git a/tests/cloud_tests/configs/modules/landscape.yaml b/tests/cloud_tests/configs/modules/landscape.yaml
new file mode 100644
index 0000000..6eb14a0
--- /dev/null
+++ b/tests/cloud_tests/configs/modules/landscape.yaml
@@ -0,0 +1,24 @@
+#
+# Setup landscape client settings
+#
+# 2016-11-17: Disabled due to this not working
+#
+enabled: false
+cloud_config: |
+  #cloud-conifg
+  landscape:
+    client:
+      log_level: "info"
+      url: "https://landscape.canonical.com/message-system";
+      ping_url: "http://landscape.canonical.com/ping";
+      data_path: "/var/lib/landscape/client"
+      http_proxy: "http://my.proxy.com/foobar";
+      https_proxy: "https://my.proxy.com/foobar";
+      tags: "server,cloud"
+      computer_title: "footitle"
+      registration_key: "fookey"
+      account_name: "fooaccount"
+collect_scripts:
+  client.conf: |
+    #!/bin/bash
+    cat /etc/landscape/client.conf
diff --git a/tests/cloud_tests/configs/modules/locale.yaml b/tests/cloud_tests/configs/modules/locale.yaml
new file mode 100644
index 0000000..be3c747
--- /dev/null
+++ b/tests/cloud_tests/configs/modules/locale.yaml
@@ -0,0 +1,17 @@
+#
+# Set locale to non-default option and verify
+#
+cloud_config: |
+  #cloud-config
+  locale: en_GB.UTF-8
+  locale_configfile: /etc/default/locale
+collect_scripts:
+  locale_default: |
+    #!/bin/bash
+    cat /etc/default/locale
+  locale_a: |
+    #!/bin/bash
+    locale -a
+  locale_gen: |
+    #!/bin/bash
+    cat /etc/locale.gen | grep -v '^#' | uniq
diff --git a/tests/cloud_tests/configs/modules/lxd_bridge.yaml b/tests/cloud_tests/configs/modules/lxd_bridge.yaml
new file mode 100644
index 0000000..956d888
--- /dev/null
+++ b/tests/cloud_tests/configs/modules/lxd_bridge.yaml
@@ -0,0 +1,28 @@
+#
+# LXD configured with directory backend and IPv4 bridge
+#
+cloud_config: |
+  #cloud-config
+  lxd:
+    init:
+      storage_backend: dir
+    bridge:
+      mode: new
+      name: lxdbr0
+      ipv4_address: 10.100.100.1
+      ipv4_netmask: 24
+      ipv4_dhcp_first: 10.100.100.100
+      ipv4_dhcp_last: 10.100.100.200
+      ipv4_nat: true
+      domain: lxd
+collect_scripts:
+  lxc: |
+    #!/bin/bash
+    which lxc
+  lxd: |
+    #!/bin/bash
+    which lxd
+  lxc-bridge: |
+    #!/bin/bash
+    ip addr show lxdbr0
+    cat /etc/default/lxd-bridge 2>/dev/null | grep -v ^#  | sort -u
diff --git a/tests/cloud_tests/configs/modules/lxd_dir.yaml b/tests/cloud_tests/configs/modules/lxd_dir.yaml
new file mode 100644
index 0000000..1e24670
--- /dev/null
+++ b/tests/cloud_tests/configs/modules/lxd_dir.yaml
@@ -0,0 +1,15 @@
+#
+# LXD configured with directory backend
+#
+cloud_config: |
+  #cloud-config
+  lxd:
+    init:
+      storage_backend: dir
+collect_scripts:
+  lxc: |
+    #!/bin/bash
+    which lxc
+  lxd: |
+    #!/bin/bash
+    which lxd
diff --git a/tests/cloud_tests/configs/modules/ntp.yaml b/tests/cloud_tests/configs/modules/ntp.yaml
new file mode 100644
index 0000000..24b1566
--- /dev/null
+++ b/tests/cloud_tests/configs/modules/ntp.yaml
@@ -0,0 +1,18 @@
+#
+# Emtpy NTP config to setup using defaults
+#
+cloud_config: |
+  #cloud-config
+  ntp:
+    pools: {}
+    servers: {}
+collect_scripts:
+  ntp_installed_empty: |
+    #!/bin/bash
+    dpkg -l | grep ntp | wc -l
+  ntp_conf_dist_empty: |
+    #!/bin/bash
+    ls /etc/ntp.conf.dist | wc -l
+  ntp_conf_empty: |
+    #!/bin/bash
+    cat /etc/ntp.conf | grep '^pool'
diff --git a/tests/cloud_tests/configs/modules/ntp_pools.yaml b/tests/cloud_tests/configs/modules/ntp_pools.yaml
new file mode 100644
index 0000000..b148abd
--- /dev/null
+++ b/tests/cloud_tests/configs/modules/ntp_pools.yaml
@@ -0,0 +1,21 @@
+#
+# NTP config using specific pools
+#
+cloud_config: |
+  #cloud-config
+  ntp:
+    pools:
+        - 0.pool.ntp.org
+        - 1.pool.ntp.org
+        - 2.pool.ntp.org
+        - 3.pool.ntp.org
+collect_scripts:
+  ntp_installed_pools: |
+    #!/bin/bash
+    dpkg -l | grep ntp | wc -l
+  ntp_conf_dist_pools: |
+    #!/bin/bash
+    ls /etc/ntp.conf.dist | wc -l
+  ntp_conf_pools: |
+    #!/bin/bash
+    cat /etc/ntp.conf | grep '^pool'
diff --git a/tests/cloud_tests/configs/modules/ntp_servers.yaml b/tests/cloud_tests/configs/modules/ntp_servers.yaml
new file mode 100644
index 0000000..40f1854
--- /dev/null
+++ b/tests/cloud_tests/configs/modules/ntp_servers.yaml
@@ -0,0 +1,18 @@
+#
+# NTP config using specific servers
+#
+cloud_config: |
+  #cloud-config
+  ntp:
+    servers:
+        - pool.ntp.org
+collect_scripts:
+  ntp_installed_servers: |
+    #!/bin/bash
+    dpkg -l | grep ntp | wc -l
+  ntp_conf_dist_servers: |
+    #!/bin/bash
+    ls /etc/ntp.conf.dist | wc -l
+  ntp_conf_servers: |
+    #!/bin/bash
+    cat /etc/ntp.conf | grep '^server'
diff --git a/tests/cloud_tests/configs/modules/package_update_upgrade_install.yaml b/tests/cloud_tests/configs/modules/package_update_upgrade_install.yaml
new file mode 100644
index 0000000..d73d733
--- /dev/null
+++ b/tests/cloud_tests/configs/modules/package_update_upgrade_install.yaml
@@ -0,0 +1,20 @@
+#
+# Update/upgrade via apt and then install a pair of packages
+#
+cloud_config: |
+  #cloud-config
+  packages:
+    - htop
+    - tree
+  package_update: true
+  package_upgrade: true
+collect_scripts:
+  apt_history_cmdline: |
+    #!/bin/bash
+    cat /var/log/apt/history.log | grep ^Commandline:
+  dpkg_htop: |
+    #!/bin/bash
+    dpkg -l | grep htop | wc -l
+  dpkg_tree: |
+    #!/bin/bash
+    dpkg -l | grep tree | wc -l
diff --git a/tests/cloud_tests/configs/modules/runcmd.yaml b/tests/cloud_tests/configs/modules/runcmd.yaml
new file mode 100644
index 0000000..fc31657
--- /dev/null
+++ b/tests/cloud_tests/configs/modules/runcmd.yaml
@@ -0,0 +1,11 @@
+#
+# Run a simple command
+#
+cloud_config: |
+  #cloud-config
+  runcmd:
+   - echo cloud-init run cmd test > /tmp/run_cmd
+collect_scripts:
+  run_cmd: |
+    #!/bin/bash
+    cat /tmp/run_cmd
diff --git a/tests/cloud_tests/configs/modules/salt_minion.yaml b/tests/cloud_tests/configs/modules/salt_minion.yaml
new file mode 100644
index 0000000..ea091a3
--- /dev/null
+++ b/tests/cloud_tests/configs/modules/salt_minion.yaml
@@ -0,0 +1,32 @@
+#
+# Create config for a salt minion
+#
+# 2016-11-17: Currently takes >60 seconds results in test failure
+#
+enabled: False
+cloud_config: |
+  #cloud-config
+  salt_minion:
+      conf:
+          master: salt.mydomain.com
+      public_key: |
+          ------BEGIN PUBLIC KEY-------
+          <key data>
+          ------END PUBLIC KEY-------
+      private_key: |
+          ------BEGIN PRIVATE KEY------
+          <key data>
+          ------END PRIVATE KEY-------
+collect_scripts:
+  minion: |
+    #!/bin/bash
+    cat /etc/salt/minion
+  minion_id: |
+    #!/bin/bash
+    cat /etc/salt/minion_id
+  minion.pem: |
+    #!/bin/bash
+    cat /etc/salt/pki/minion/minion.pem
+  minion.pub: |
+    #!/bin/bash
+    cat /etc/salt/pki/minion/minion.pub
diff --git a/tests/cloud_tests/configs/modules/seed_random_command.yaml b/tests/cloud_tests/configs/modules/seed_random_command.yaml
new file mode 100644
index 0000000..aa2c681
--- /dev/null
+++ b/tests/cloud_tests/configs/modules/seed_random_command.yaml
@@ -0,0 +1,16 @@
+#
+# Use uuid to create a random string
+#
+# 2016-11-15 Disabled as this is not working currently
+#
+enabled: False
+cloud_config: |
+  #cloud-config
+  random_seed:
+    command: ["cat", "/proc/sys/kernel/random/uuid"]
+    command_required: true
+    file: /root/seed
+collect_scripts:
+  seed_data: |
+    #!/bin/bash
+    cat /root/seed
diff --git a/tests/cloud_tests/configs/modules/seed_random_data.yaml b/tests/cloud_tests/configs/modules/seed_random_data.yaml
new file mode 100644
index 0000000..6f8fd4a
--- /dev/null
+++ b/tests/cloud_tests/configs/modules/seed_random_data.yaml
@@ -0,0 +1,13 @@
+#
+# Push in random raw string to set as seed
+#
+cloud_config: |
+  #cloud-config
+  random_seed:
+    data: 'MYUb34023nD:LFDK10913jk;dfnk:Df'
+    encoding: raw
+    file: /root/seed
+collect_scripts:
+  seed_data: |
+    #!/bin/bash
+    cat /root/seed
diff --git a/tests/cloud_tests/configs/modules/set_hostname.yaml b/tests/cloud_tests/configs/modules/set_hostname.yaml
new file mode 100644
index 0000000..05126f0
--- /dev/null
+++ b/tests/cloud_tests/configs/modules/set_hostname.yaml
@@ -0,0 +1,16 @@
+#
+# Set the hostname and update /etc/hosts
+#
+cloud_config: |
+  #cloud-config
+  hostname: myhostname
+collect_scripts:
+  hosts: |
+    #!/bin/bash
+    cat /etc/hosts | grep ^127
+  hostname: |
+    #!/bin/bash
+    hostname
+  fqdn: |
+    #!/bin/bash
+    hostname --fqdn
diff --git a/tests/cloud_tests/configs/modules/set_hostname_fqdn.yaml b/tests/cloud_tests/configs/modules/set_hostname_fqdn.yaml
new file mode 100644
index 0000000..3f2a085
--- /dev/null
+++ b/tests/cloud_tests/configs/modules/set_hostname_fqdn.yaml
@@ -0,0 +1,18 @@
+#
+# Set the hostname and update /etc/hosts
+#
+cloud_config: |
+  #cloud-config
+  manage_etc_hosts: true
+  hostname: myhostname
+  fqdn: host.myorg.com
+collect_scripts:
+  hosts: |
+    #!/bin/bash
+    cat /etc/hosts | grep ^127
+  hostname: |
+    #!/bin/bash
+    hostname
+  fqdn: |
+    #!/bin/bash
+    hostname --fqdn
diff --git a/tests/cloud_tests/configs/modules/set_password.yaml b/tests/cloud_tests/configs/modules/set_password.yaml
new file mode 100644
index 0000000..8efb818
--- /dev/null
+++ b/tests/cloud_tests/configs/modules/set_password.yaml
@@ -0,0 +1,15 @@
+#
+# Set password of default user
+#
+cloud_config: |
+  #cloud-config
+  password: password
+  chpasswd: { expire: False }
+  ssh_pwauth: True
+collect_scripts:
+  shadow: |
+    #!/bin/bash
+    cat /etc/shadow
+  sshd_config: |
+    #!/bin/bash
+    cat /etc/ssh/sshd_config  | grep '^PasswordAuth'
diff --git a/tests/cloud_tests/configs/modules/set_password_expire.yaml b/tests/cloud_tests/configs/modules/set_password_expire.yaml
new file mode 100644
index 0000000..e70d9ac
--- /dev/null
+++ b/tests/cloud_tests/configs/modules/set_password_expire.yaml
@@ -0,0 +1,26 @@
+#
+# Expire password for all users
+#
+cloud_config: |
+  #cloud-config
+  chpasswd: { expire: True }
+  users:
+    - name: tom
+      password: $1$xyz$sPMsLNmf66Ohl.ol6JvzE.
+      lock_passwd: false
+    - name: dick
+      password: $1$xyz$sPMsLNmf66Ohl.ol6JvzE.
+      lock_passwd: false
+    - name: harry
+      password: $1$xyz$sPMsLNmf66Ohl.ol6JvzE.
+      lock_passwd: false
+    - name: jane
+      password: $1$xyz$sPMsLNmf66Ohl.ol6JvzE.
+      lock_passwd: false
+collect_scripts:
+  shadow: |
+    #!/bin/bash
+    cat /etc/shadow
+  sshd_config: |
+    #!/bin/bash
+    cat /etc/ssh/sshd_config  | grep '^PasswordAuth'
diff --git a/tests/cloud_tests/configs/modules/set_password_list.yaml b/tests/cloud_tests/configs/modules/set_password_list.yaml
new file mode 100644
index 0000000..789fd0e
--- /dev/null
+++ b/tests/cloud_tests/configs/modules/set_password_list.yaml
@@ -0,0 +1,31 @@
+#
+# Set password of list of users
+#
+cloud_config: |
+  #cloud-config
+  ssh_pwauth: yes
+  users:
+    - name: tom
+      password: $1$xyz$sPMsLNmf66Ohl.ol6JvzE.
+      lock_passwd: false
+    - name: dick
+      password: $1$xyz$sPMsLNmf66Ohl.ol6JvzE.
+      lock_passwd: false
+    - name: harry
+      password: $1$xyz$sPMsLNmf66Ohl.ol6JvzE.
+      lock_passwd: false
+    - name: jane
+      password: $1$xyz$sPMsLNmf66Ohl.ol6JvzE.
+      lock_passwd: false
+  chpasswd:
+    list: |
+      tom:mypassword123!
+      dick:R
+      harry:Random
+collect_scripts:
+  shadow: |
+    #!/bin/bash
+    cat /etc/shadow
+  sshd_config: |
+    #!/bin/bash
+    cat /etc/ssh/sshd_config  | grep '^PasswordAuth'
diff --git a/tests/cloud_tests/configs/modules/snappy.yaml b/tests/cloud_tests/configs/modules/snappy.yaml
new file mode 100644
index 0000000..2e0b0d9
--- /dev/null
+++ b/tests/cloud_tests/configs/modules/snappy.yaml
@@ -0,0 +1,11 @@
+#
+# Install snappy
+#
+cloud_config: |
+  #cloud-config
+  snappy:
+    system_snappy: auto
+collect_scripts:
+  snap_version: |
+    #!/bin/bash
+    snap --version
diff --git a/tests/cloud_tests/configs/modules/ssh_auth_key_fingerprints_disable.yaml b/tests/cloud_tests/configs/modules/ssh_auth_key_fingerprints_disable.yaml
new file mode 100644
index 0000000..715331a
--- /dev/null
+++ b/tests/cloud_tests/configs/modules/ssh_auth_key_fingerprints_disable.yaml
@@ -0,0 +1,11 @@
+#
+# Disable fingerprint printing
+#
+cloud_config: |
+  #cloud-config
+  ssh_genkeytypes: []
+  no_ssh_fingerprints: true
+collect_scripts:
+  syslog: |
+    #!/bin/bash
+    cat /var/log/syslog
diff --git a/tests/cloud_tests/configs/modules/ssh_auth_key_fingerprints_enable.yaml b/tests/cloud_tests/configs/modules/ssh_auth_key_fingerprints_enable.yaml
new file mode 100644
index 0000000..9d37723
--- /dev/null
+++ b/tests/cloud_tests/configs/modules/ssh_auth_key_fingerprints_enable.yaml
@@ -0,0 +1,14 @@
+#
+# Print auth keys with different hash than md5
+#
+cloud_config: |
+  #cloud-config
+  ssh_genkeytypes:
+  - ecdsa
+  - ed25519
+  ssh_authorized_keys:
+    - ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDXW9Gg5H7ehjdSc6qDzwNtgCy94XYHhEYlXZMO2+FJrH3wfHGiMfCwOHxcOMt2QiXItULthdeQWS9QjBSSjVRXf6731igFrqPFyS9qBlOQ5D29C4HBXFnQggGVpBNJ82IRJv7szbbe/vpgLBP4kttUza9Dr4e1YM1ln4PRnjfXea6T0m+m1ixNb5432pTXlqYOnNOxSIm1gHgMLxPuDrJvQERDKrSiKSjIdyC9Jd8t2e1tkNLY0stmckVRbhShmcJvlyofHWbc2Ca1mmtP7MlS1VQnfLkvU1IrFwkmaQmaggX6WR6coRJ6XFXdWcq/AI2K6GjSnl1dnnCxE8VCEXBlXgFzad+PMSG4yiL5j8Oo1ZVpkTdgBnw4okGqTYCXyZg6X00As9IBNQfZMFlQXlIo4FiWgj3CO5QHQOyOX6FuEumaU13GnERrSSdp9tCs1Qm3/DG2RSCQBWTfcgMcStIvKqvJ3IjFn0vGLvI3Ampnq9q1SHwmmzAPSdzcMA76HyMUA5VWaBvWHlUxzIM6unxZASnwvuCzpywSEB5J2OF+p6H+cStJwQ32XwmOG8pLp1srlVWpqZI58Du/lzrkPqONphoZx0LDV86w7RUz1ksDzAdcm0tvmNRFMN1a0frDs506oA3aWK0oDk4Nmvk8sXGTYYw3iQSkOvDUUlIsqdaO+w==
+collect_scripts:
+  syslog: |
+    #!/bin/bash
+    cat /var/log/syslog
diff --git a/tests/cloud_tests/configs/modules/ssh_import_id.yaml b/tests/cloud_tests/configs/modules/ssh_import_id.yaml
new file mode 100644
index 0000000..6e08a4c
--- /dev/null
+++ b/tests/cloud_tests/configs/modules/ssh_import_id.yaml
@@ -0,0 +1,12 @@
+#
+# Import a user's ssh key via gh or lp
+#
+cloud_config: |
+  #cloud-config
+  ssh_import_id:
+    - gh:powersj
+    - lp:smoser
+collect_scripts:
+  auth_keys_ubuntu: |
+    #!/bin/bash
+    cat /home/ubuntu/.ssh/authorized_keys
diff --git a/tests/cloud_tests/configs/modules/ssh_keys_generate.yaml b/tests/cloud_tests/configs/modules/ssh_keys_generate.yaml
new file mode 100644
index 0000000..63ec8a5
--- /dev/null
+++ b/tests/cloud_tests/configs/modules/ssh_keys_generate.yaml
@@ -0,0 +1,42 @@
+#
+# SSH keys generated using cloud-init
+#
+cloud_config: |
+  #cloud-config
+  ssh_genkeytypes:
+  - ecdsa
+  - ed25519
+  authkey_hash: sha512
+  ssh_authorized_keys:
+  - ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDXW9Gg5H7ehjdSc6qDzwNtgCy94XYHhEYlXZMO2+FJrH3wfHGiMfCwOHxcOMt2QiXItULthdeQWS9QjBSSjVRXf6731igFrqPFyS9qBlOQ5D29C4HBXFnQggGVpBNJ82IRJv7szbbe/vpgLBP4kttUza9Dr4e1YM1ln4PRnjfXea6T0m+m1ixNb5432pTXlqYOnNOxSIm1gHgMLxPuDrJvQERDKrSiKSjIdyC9Jd8t2e1tkNLY0stmckVRbhShmcJvlyofHWbc2Ca1mmtP7MlS1VQnfLkvU1IrFwkmaQmaggX6WR6coRJ6XFXdWcq/AI2K6GjSnl1dnnCxE8VCEXBlXgFzad+PMSG4yiL5j8Oo1ZVpkTdgBnw4okGqTYCXyZg6X00As9IBNQfZMFlQXlIo4FiWgj3CO5QHQOyOX6FuEumaU13GnERrSSdp9tCs1Qm3/DG2RSCQBWTfcgMcStIvKqvJ3IjFn0vGLvI3Ampnq9q1SHwmmzAPSdzcMA76HyMUA5VWaBvWHlUxzIM6unxZASnwvuCzpywSEB5J2OF+p6H+cStJwQ32XwmOG8pLp1srlVWpqZI58Du/lzrkPqONphoZx0LDV86w7RUz1ksDzAdcm0tvmNRFMN1a0frDs506oA3aWK0oDk4Nmvk8sXGTYYw3iQSkOvDUUlIsqdaO+w==
+collect_scripts:
+  auth_keys_root: |
+    #!/bin/bash
+    cat /root/.ssh/authorized_keys
+  auth_keys_ubuntu: |
+    #!/bin/bash
+    cat /home/ubuntu/ssh/authorized_keys
+  dsa_public: |
+    #!/bin/bash
+    cat /etc/ssh/ssh_host_dsa_key.pub
+  dsa_private: |
+    #!/bin/bash
+    cat /etc/ssh/ssh_host_dsa_key
+  rsa_public: |
+    #!/bin/bash
+    cat /etc/ssh/ssh_host_rsa_key.pub
+  rsa_private: |
+    #!/bin/bash
+    cat /etc/ssh/ssh_host_rsa_key
+  ecdsa_public: |
+    #!/bin/bash
+    cat /etc/ssh/ssh_host_ecdsa_key.pub
+  ecdsa_private: |
+    #!/bin/bash
+    cat /etc/ssh/ssh_host_ecdsa_key
+  ed25519_public: |
+    #!/bin/bash
+    cat /etc/ssh/ssh_host_ed25519_key.pub
+  ed25519_private: |
+    #!/bin/bash
+    cat /etc/ssh/ssh_host_ed25519_key
diff --git a/tests/cloud_tests/configs/modules/ssh_keys_provided.yaml b/tests/cloud_tests/configs/modules/ssh_keys_provided.yaml
new file mode 100644
index 0000000..ae1990f
--- /dev/null
+++ b/tests/cloud_tests/configs/modules/ssh_keys_provided.yaml
@@ -0,0 +1,99 @@
+#
+# SSH keys provided via cloud config
+#
+cloud_config: |
+  #cloud-config
+  disable_root: false
+  ssh_authorized_keys:
+    - ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDXW9Gg5H7ehjdSc6qDzwNtgCy94XYHhEYlXZMO2+FJrH3wfHGiMfCwOHxcOMt2QiXItULthdeQWS9QjBSSjVRXf6731igFrqPFyS9qBlOQ5D29C4HBXFnQggGVpBNJ82IRJv7szbbe/vpgLBP4kttUza9Dr4e1YM1ln4PRnjfXea6T0m+m1ixNb5432pTXlqYOnNOxSIm1gHgMLxPuDrJvQERDKrSiKSjIdyC9Jd8t2e1tkNLY0stmckVRbhShmcJvlyofHWbc2Ca1mmtP7MlS1VQnfLkvU1IrFwkmaQmaggX6WR6coRJ6XFXdWcq/AI2K6GjSnl1dnnCxE8VCEXBlXgFzad+PMSG4yiL5j8Oo1ZVpkTdgBnw4okGqTYCXyZg6X00As9IBNQfZMFlQXlIo4FiWgj3CO5QHQOyOX6FuEumaU13GnERrSSdp9tCs1Qm3/DG2RSCQBWTfcgMcStIvKqvJ3IjFn0vGLvI3Ampnq9q1SHwmmzAPSdzcMA76HyMUA5VWaBvWHlUxzIM6unxZASnwvuCzpywSEB5J2OF+p6H+cStJwQ32XwmOG8pLp1srlVWpqZI58Du/lzrkPqONphoZx0LDV86w7RUz1ksDzAdcm0tvmNRFMN1a0frDs506oA3aWK0oDk4Nmvk8sXGTYYw3iQSkOvDUUlIsqdaO+w==
+  ssh_keys:
+    rsa_private: |
+      -----BEGIN RSA PRIVATE KEY-----
+      MIIEowIBAAKCAQEAtPx6PqN3iSEsnTtibyIEy52Tra8T5fn0ryXyg46Di2NBwdnj
+      o8trNv9jenfV/UhmePl58lXjT43wV8OCMl6KsYXyBdegM35NNtono4I4mLLKFMR9
+      9TOtDn6iYcaNenVhF3ZCj9Z2nNOlTrdc0uchHqKMrxLjCRCUrL91Uf+xioTF901Y
+      RM+ZqC5lT92yAL76F4qPF+Lq1QtUfNfUIwwvOp5ccDZLPxij0YvyBzubYye9hJHu
+      yjbJv78R4JHV+L2WhzSoX3W/6WrxVzeXqFGqH894ccOaC/7tnqSP6V8lIQ6fE2+c
+      DurJcpM3CJRgkndGHjtU55Y71YkcdLksSMvezQIDAQABAoIBAQCrU4IJP8dNeaj5
+      IpkY6NQvR/jfZqfogYi+MKb1IHin/4rlDfUvPcY9pt8ttLlObjYK+OcWn3Vx/sRw
+      4DOkqNiUGl80Zp1RgZNohHUXlJMtAbrIlAVEk+mTmg7vjfyp2unRQvLZpMRdywBm
+      lq95OrCghnG03aUsFJUZPpi5ydnwbA12ma+KHkG0EzaVlhA7X9N6z0K6U+zue2gl
+      goMLt/MH0rsYawkHrwiwXaIFQeyV4MJP0vmrZLbFk1bycu9X/xPtTYotWyWo4eKA
+      cb05uu04qwexkKHDM0KXtT0JecbTo2rOefFo8Uuab6uJY+fEHNocZ+v1vLA4aOxJ
+      ovp1JuXlAoGBAOWYNgKrlTfy5n0sKsNk+1RuL2jHJZJ3HMd0EIt7/fFQN3Fi08Hu
+      jtntqD30Wj+DJK8b8Lrt66FruxyEJm5VhVmwkukrLR5ige2f6ftZnoFCmdyy+0zP
+      dnPZSUe2H5ZPHa+qthJgHLn+al2P04tGh+1fGHC2PbP+e0Co+/ZRIOxrAoGBAMnN
+      IEen9/FRsqvnDd36I8XnJGskVRTZNjylxBmbKcuMWm+gNhOI7gsCAcqzD4BYZjjW
+      pLhrt/u9p+l4MOJy6OUUdM/okg12SnJEGryysOcVBcXyrvOfklWnANG4EAH5jt1N
+      ftTb1XTxzvWVuR/WJK0B5MZNYM71cumBdUDtPi+nAoGAYmoIXMSnxb+8xNL10aOr
+      h9ljQQp8NHgSQfyiSufvRk0YNuYh1vMnEIsqnsPrG2Zfhx/25GmvoxXGssaCorDN
+      5FAn6QK06F1ZTD5L0Y3sv4OI6G1gAuC66ZWuL6sFhyyKkQ4f1WiVZ7SCa3CHQSAO
+      i9VDaKz1bf4bXvAQcNj9v9kCgYACSOZCqW4vN0OUmqsXhkt9ZB6Pb/veno70pNPR
+      jmYsvcwQU3oJQpWfXkhy6RAV3epaXmPDCsUsfns2M3wqNC7a2R5xdCqjKGGzZX4A
+      AO3rz9se4J6Gd5oKijeCKFlWDGNHsibrdgm2pz42nZlY+O21X74dWKbt8O16I1MW
+      hxkbJQKBgAXfuen/srVkJgPuqywUYag90VWCpHsuxdn+fZJa50SyZADr+RbiDfH2
+      vek8Uo8ap8AEsv4Rfs9opUcUZevLp3g2741eOaidHVLm0l4iLIVl03otGOqvSzs+
+      A3tFPEOxauXpzCt8f8eXsz0WQXAgIKW2h8zu5QHjomioU3i27mtE
+      -----END RSA PRIVATE KEY-----
+    rsa_public: ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC0/Ho+o3eJISydO2JvIgTLnZOtrxPl+fSvJfKDjoOLY0HB2eOjy2s2/2N6d9X9SGZ4+XnyVeNPjfBXw4IyXoqxhfIF16Azfk022iejgjiYssoUxH31M60OfqJhxo16dWEXdkKP1nac06VOt1zS5yEeooyvEuMJEJSsv3VR/7GKhMX3TVhEz5moLmVP3bIAvvoXio8X4urVC1R819QjDC86nlxwNks/GKPRi/IHO5tjJ72Eke7KNsm/vxHgkdX4vZaHNKhfdb/pavFXN5eoUaofz3hxw5oL/u2epI/pXyUhDp8Tb5wO6slykzcIlGCSd0YeO1TnljvViRx0uSxIy97N root@xenial-lxd
+    dsa_private: |
+      -----BEGIN DSA PRIVATE KEY-----
+      MIIBuwIBAAKBgQD5Fstc23IVSDe6k4DNP8smPKuEWUvHDTOGaXrhOVAfzZ6+jklP
+      55mzvC7jO53PWWC31hq10xBoWdev0WtcNF9Tv+4bAa1263y51Rqo4GI7xx+xic1d
+      mLqqfYijBT9k48J/1tV0cs1Wjs6FP/IJTD/kYVC930JjYQMi722lBnUxsQIVAL7i
+      z3fTGKTvSzvW0wQlwnYpS2QFAoGANp+KdyS9V93HgxGQEN1rlj/TSv/a3EVdCKtE
+      nQf55aPHxDAVDVw5JtRh4pZbbRV4oGRPc9KOdjo5BU28vSM3Lmhkb+UaaDXwHkgI
+      nK193o74DKjADWZxuLyyiKHiMOhxozoxDfjWxs8nz6uqvSW0pr521EwIY6RajbED
+      nZ2a3GkCgYEAyoUomNRB6bmpsIfzt8zdtqLP5umIj2uhr9MVPL8/QdbxmJ72Z7pf
+      Q2z1B7QAdIBGOlqJXtlau7ABhWK29Efe+99ObyTSSdDc6RCDeAwUmBAiPRQhDH2E
+      wExw3doDSCUb28L1B50wBzQ8mC3KXp6C7IkBXWspb16DLHUHFSI8bkICFA5kVUcW
+      nCPOXEQsayANi8+Cb7BH
+      -----END DSA PRIVATE KEY-----
+    dsa_public: ssh-dss AAAAB3NzaC1kc3MAAACBAPkWy1zbchVIN7qTgM0/yyY8q4RZS8cNM4ZpeuE5UB/Nnr6OSU/nmbO8LuM7nc9ZYLfWGrXTEGhZ16/Ra1w0X1O/7hsBrXbrfLnVGqjgYjvHH7GJzV2Yuqp9iKMFP2Tjwn/W1XRyzVaOzoU/8glMP+RhUL3fQmNhAyLvbaUGdTGxAAAAFQC+4s930xik70s71tMEJcJ2KUtkBQAAAIA2n4p3JL1X3ceDEZAQ3WuWP9NK/9rcRV0Iq0SdB/nlo8fEMBUNXDkm1GHillttFXigZE9z0o52OjkFTby9IzcuaGRv5RpoNfAeSAicrX3ejvgMqMANZnG4vLKIoeIw6HGjOjEN+NbGzyfPq6q9JbSmvnbUTAhjpFqNsQOdnZrcaQAAAIEAyoUomNRB6bmpsIfzt8zdtqLP5umIj2uhr9MVPL8/QdbxmJ72Z7pfQ2z1B7QAdIBGOlqJXtlau7ABhWK29Efe+99ObyTSSdDc6RCDeAwUmBAiPRQhDH2EwExw3doDSCUb28L1B50wBzQ8mC3KXp6C7IkBXWspb16DLHUHFSI8bkI= root@xenial-lxd
+    ed25519_private: |
+      -----BEGIN OPENSSH PRIVATE KEY-----
+      b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
+      QyNTUxOQAAACDbnQGUruL42aVVsyHeaV5mYNTOhteXao0Nl5DVThJ2+QAAAJgwt+lcMLfp
+      XAAAAAtzc2gtZWQyNTUxOQAAACDbnQGUruL42aVVsyHeaV5mYNTOhteXao0Nl5DVThJ2+Q
+      AAAEDQlFZpz9q8+/YJHS9+jPAqy2ZT6cGEv8HTB6RZtTjd/dudAZSu4vjZpVWzId5pXmZg
+      1M6G15dqjQ2XkNVOEnb5AAAAD3Jvb3RAeGVuaWFsLWx4ZAECAwQFBg==
+      -----END OPENSSH PRIVATE KEY-----
+    ed25519_public: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINudAZSu4vjZpVWzId5pXmZg1M6G15dqjQ2XkNVOEnb5 root@xenial-lxd
+    ecdsa_private: |
+      -----BEGIN EC PRIVATE KEY-----
+      MHcCAQEEIDuK+QFc1wmyJY8uDqQVa1qHte30Rk/fdLxGIBkwJAyOoAoGCCqGSM49
+      AwEHoUQDQgAEWxLlO+TL8gL91eET9p/HFQbqR1A691AkJgZk3jY5mpZqxgX4vcgb
+      7f/CtXuM6s2svcDJqAeXr6Wk8OJJcMxylA==
+      -----END EC PRIVATE KEY-----
+    ecdsa_public: ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBFsS5Tvky/IC/dXhE/afxxUG6kdQOvdQJCYGZN42OZqWasYF+L3IG+3/wrV7jOrNrL3AyagHl6+lpPDiSXDMcpQ= root@xenial-lxd
+collect_scripts:
+  auth_keys_root: |
+    #!/bin/bash
+    cat /root/.ssh/authorized_keys
+  auth_keys_ubuntu: |
+    #!/bin/bash
+    cat /home/ubuntu/ssh/authorized_keys
+  dsa_public: |
+    #!/bin/bash
+    cat /etc/ssh/ssh_host_dsa_key.pub
+  dsa_private: |
+    #!/bin/bash
+    cat /etc/ssh/ssh_host_dsa_key
+  rsa_public: |
+    #!/bin/bash
+    cat /etc/ssh/ssh_host_rsa_key.pub
+  rsa_private: |
+    #!/bin/bash
+    cat /etc/ssh/ssh_host_rsa_key
+  ecdsa_public: |
+    #!/bin/bash
+    cat /etc/ssh/ssh_host_ecdsa_key.pub
+  ecdsa_private: |
+    #!/bin/bash
+    cat /etc/ssh/ssh_host_ecdsa_key
+  ed25519_public: |
+    #!/bin/bash
+    cat /etc/ssh/ssh_host_ed25519_key.pub
+  ed25519_private: |
+    #!/bin/bash
+    cat /etc/ssh/ssh_host_ed25519_key
diff --git a/tests/cloud_tests/configs/modules/timezone.yaml b/tests/cloud_tests/configs/modules/timezone.yaml
new file mode 100644
index 0000000..b0f7413
--- /dev/null
+++ b/tests/cloud_tests/configs/modules/timezone.yaml
@@ -0,0 +1,10 @@
+#
+# Set system timezone
+#
+cloud_config: |
+  #cloud-config
+  timezone: US/Aleutian
+collect_scripts:
+  timezone: |
+    #!/bin/bash
+    date +%Z
diff --git a/tests/cloud_tests/configs/modules/user_groups.yaml b/tests/cloud_tests/configs/modules/user_groups.yaml
new file mode 100644
index 0000000..1356efb
--- /dev/null
+++ b/tests/cloud_tests/configs/modules/user_groups.yaml
@@ -0,0 +1,48 @@
+#
+# Create groups and users with various options
+#
+cloud_config: |
+  #cloud-config
+  # Add groups to the system
+  groups:
+    - secret: [foobar,barfoo]
+    - cloud-users
+
+  # Add users to the system. Users are added after groups are added.
+  users:
+    - default
+    - name: foobar
+      gecos: Foo B. Bar
+      primary-group: foobar
+      groups: users
+      expiredate: 2038-01-19
+      lock_passwd: false
+      passwd: $6$j212wezy$7H/1LT4f9/N3wpgNunhsIqtMj62OKiS3nyNwuizouQc3u7MbYCarYeAHWYPYb2FT.lbioDm2RrkJPb9BZMN1O/
+    - name: barfoo
+      gecos: Bar B. Foo
+      sudo: ALL=(ALL) NOPASSWD:ALL
+      groups: cloud-users
+      lock_passwd: true
+    - name: cloudy
+      gecos: Magic Cloud App Daemon User
+      inactive: true
+      system: true
+collect_scripts:
+  group_ubuntu: |
+    #!/bin/bash
+    getent group ubuntu
+  group_cloud_users: |
+    #!/bin/bash
+    getent group cloud-users
+  user_ubuntu: |
+    #!/bin/bash
+    getent passwd ubuntu
+  user_foobar: |
+    #!/bin/bash
+    getent passwd foobar
+  user_barfoo: |
+    #!/bin/bash
+    getent passwd barfoo
+  user_cloudy: |
+    #!/bin/bash
+    getent passwd cloudy
diff --git a/tests/cloud_tests/configs/modules/write_files.yaml b/tests/cloud_tests/configs/modules/write_files.yaml
new file mode 100644
index 0000000..3a3bbce
--- /dev/null
+++ b/tests/cloud_tests/configs/modules/write_files.yaml
@@ -0,0 +1,40 @@
+#
+# Write various file types
+#
+cloud_config: |
+  #cloud-config
+  write_files:
+  -   encoding: b64
+      content: CiMgVGhpcyBmaWxlIGNvbnRyb2xzIHRoZSBzdGF0ZSBvZiBTRUxpbnV4
+      owner: root:root
+      path: /root/file_b64
+      permissions: '0644'
+  -   content: |
+          # My new /root/file_text
+
+          SMBDOPTIONS="-D"
+      path: /root/file_text
+  -   content: !!binary |
+          f0VMRgIBAQAAAAAAAAAAAAIAPgABAAAAwARAAAAAAABAAAAAAAAAAJAVAAAAAAAAAAAAAEAAOAAI
+          AEAAHgAdAAYAAAAFAAAAQAAAAAAAAABAAEAAAAAAAEAAQAAAAAAAwAEAAAAAAADAAQAAAAAAAAgA
+          AAAAAAAAAwAAAAQAAAAAAgAAAAAAAAACQAAAAAAAAAJAAAAAAAAcAAAAAAAAABwAAAAAAAAAAQAA
+      path: /root/file_binary
+      permissions: '0555'
+  -   encoding: gzip
+      content: !!binary |
+          H4sIAIDb/U8C/1NW1E/KzNMvzuBKTc7IV8hIzcnJVyjPL8pJ4QIA6N+MVxsAAAA=
+      path: /root/file_gzip
+      permissions: '0755'
+collect_scripts:
+  file_b64: |
+    #!/bin/bash
+    file /root/file_b64
+  file_text: |
+    #!/bin/bash
+    file /root/file_text
+  file_binary: |
+    #!/bin/bash
+    file /root/file_binary
+  file_gzip: |
+    #!/bin/bash
+    file /root/file_gzip
diff --git a/tests/cloud_tests/images/__init__.py b/tests/cloud_tests/images/__init__.py
new file mode 100644
index 0000000..997865e
--- /dev/null
+++ b/tests/cloud_tests/images/__init__.py
@@ -0,0 +1,6 @@
+def get_image(platform, config):
+    """
+    get image from platform object using os_name, looking up img_conf in main
+    config file
+    """
+    return platform.get_image(config)
diff --git a/tests/cloud_tests/images/base.py b/tests/cloud_tests/images/base.py
new file mode 100644
index 0000000..02d928c
--- /dev/null
+++ b/tests/cloud_tests/images/base.py
@@ -0,0 +1,60 @@
+class Image(object):
+    """
+    Base class for images
+    """
+    platform_name = None
+
+    def __init__(self, name, config, platform):
+        """
+        setup
+        """
+        self.name = name
+        self.config = config
+        self.platform = platform
+
+    def __str__(self):
+        """
+        a brief description of the image
+        """
+        return '-'.join((self.properties['os'], self.properties['release']))
+
+    @property
+    def properties(self):
+        """
+        {} containing: 'arch', 'os', 'version', 'release'
+        """
+        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={}):
+        """
+        execute command in image, modifying image
+        """
+        raise NotImplementedError
+
+    def push_file(self, local_path, remote_path):
+        """
+        copy file at 'local_path' to instance at 'remote_path', modifying image
+        """
+        raise NotImplementedError
+
+    def run_script(self, script):
+        """
+        run script in image, modifying image
+        return_value: script output
+        """
+        raise NotImplementedError
+
+    def snapshot(self):
+        """
+        create snapshot of image, block until done
+        """
+        raise NotImplementedError
+
+    def destroy(self):
+        """
+        clean up data associated with image
+        """
+        pass
diff --git a/tests/cloud_tests/images/lxd.py b/tests/cloud_tests/images/lxd.py
new file mode 100644
index 0000000..b06c980
--- /dev/null
+++ b/tests/cloud_tests/images/lxd.py
@@ -0,0 +1,88 @@
+from tests.cloud_tests.images import base
+from tests.cloud_tests.snapshots import lxd as lxd_snapshot
+
+
+class LXDImage(base.Image):
+    """
+    LXD backed image
+    """
+    platform_name = "lxd"
+
+    def __init__(self, name, config, platform, pylxd_image):
+        """
+        setup
+        """
+        self.platform = platform
+        self._pylxd_image = pylxd_image
+        self._instance = None
+        super(LXDImage, self).__init__(name, config, platform)
+
+    @property
+    def pylxd_image(self):
+        self._pylxd_image.sync()
+        return self._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'))
+        return self._instance
+
+    @property
+    def properties(self):
+        """
+        {} containing: 'arch', 'os', 'version', 'release'
+        """
+        properties = self.pylxd_image.properties
+        return {
+            'arch': properties.get('architecture'),
+            'os': properties.get('os'),
+            'version': properties.get('version'),
+            'release': properties.get('release'),
+        }
+
+    def execute(self, *args, **kwargs):
+        """
+        execute command in image, modifying image
+        """
+        return self.instance.execute(*args, **kwargs)
+
+    def push_file(self, local_path, remote_path):
+        """
+        copy file at 'local_path' to instance at 'remote_path', modifying image
+        """
+        return self.instance.push_file(local_path, remote_path)
+
+    def run_script(self, script):
+        """
+        run script in image, modifying image
+        return_value: script output
+        """
+        return self.instance.run_script(script)
+
+    def snapshot(self):
+        """
+        create snapshot of image, block until done
+        """
+        # 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'))
+        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)
+
+    def destroy(self):
+        """
+        clean up data associated with image
+        """
+        if self._instance:
+            self._instance.destroy()
+        self.pylxd_image.delete(wait=True)
+        super(LXDImage, self).destroy()
diff --git a/tests/cloud_tests/instances/__init__.py b/tests/cloud_tests/instances/__init__.py
new file mode 100644
index 0000000..fd9cbaf
--- /dev/null
+++ b/tests/cloud_tests/instances/__init__.py
@@ -0,0 +1,5 @@
+def get_instance(snapshot, *args, **kwargs):
+    """
+    get instance from snapshot
+    """
+    return snapshot.launch(*args, **kwargs)
diff --git a/tests/cloud_tests/instances/base.py b/tests/cloud_tests/instances/base.py
new file mode 100644
index 0000000..fd5bd41
--- /dev/null
+++ b/tests/cloud_tests/instances/base.py
@@ -0,0 +1,116 @@
+import os
+import uuid
+
+
+class Instance(object):
+    """
+    Base instance object
+    """
+    platform_name = None
+
+    def __init__(self, name):
+        """
+        setup
+        """
+        self.name = name
+
+    def execute(self, command, stdin=None, stdout=None, stderr=None, env={}):
+        """
+        command: the command to execute as root inside the image
+        stdin, stderr, stdout: file handles
+        env: environment variables
+
+        Execute assumes functional networking and execution as root with the
+        target filesystem being available at /.
+
+        return_value: tuple containing stdout data, stderr data, exit code
+        """
+        raise NotImplementedError
+
+    def read_data(self, remote_path, encode=False):
+        """
+        read_data from instance filesystem
+        remote_path: path in instance
+        decode: return as string
+        return_value: data as str or bytes
+        """
+        raise NotImplementedError
+
+    def write_data(self, remote_path, data):
+        """
+        write data to instance filesystem
+        remote_path: path in instance
+        data: data to write, either str or bytes
+        """
+        raise NotImplementedError
+
+    def pull_file(self, remote_path, local_path):
+        """
+        copy file at 'remote_path', from instance to 'local_path'
+        """
+        with open(local_path, 'wb') as fp:
+            fp.write(self.read_data(remote_path), encode=True)
+
+    def push_file(self, local_path, remote_path):
+        """
+        copy file at 'local_path' to instance at 'remote_path'
+        """
+        with open(local_path, 'rb') as fp:
+            self.write_data(remote_path, fp.read())
+
+    def run_script(self, script):
+        """
+        run script in target and return stdout
+        """
+        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
+
+    def console_log(self):
+        """
+        return_value: bytes of this instance’s console
+        """
+        raise NotImplementedError
+
+    def reboot(self, wait=True):
+        """
+        reboot instance
+        """
+        raise NotImplementedError
+
+    def shutdown(self, wait=True):
+        """
+        shutdown instance
+        """
+        raise NotImplementedError
+
+    def start(self, wait=True):
+        """
+        start instance
+        """
+        raise NotImplementedError
+
+    def destroy(self):
+        """
+        clean up instance
+        """
+        pass
+
+    def _wait_for_cloud_init(self, wait_time):
+        """
+        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)
+
+        (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))
diff --git a/tests/cloud_tests/instances/lxd.py b/tests/cloud_tests/instances/lxd.py
new file mode 100644
index 0000000..39bd5e5
--- /dev/null
+++ b/tests/cloud_tests/instances/lxd.py
@@ -0,0 +1,117 @@
+from tests.cloud_tests.instances import base
+
+
+class LXDInstance(base.Instance):
+    """
+    LXD container backed instance
+    """
+    platform_name = "lxd"
+
+    def __init__(self, name, platform, pylxd_container):
+        """
+        setup
+        """
+        self.platform = platform
+        self._pylxd_container = pylxd_container
+        super(LXDInstance, self).__init__(name)
+
+    @property
+    def pylxd_container(self):
+        self._pylxd_container.sync()
+        return self._pylxd_container
+
+    def execute(self, command, stdin=None, stdout=None, stderr=None, env={}):
+        """
+        command: the command to execute as root inside the image
+        stdin, stderr, stdout: file handles
+        env: environment variables
+
+        Execute assumes functional networking and execution as root with the
+        target filesystem being available at /.
+
+        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
+        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,)
+
+    def read_data(self, remote_path, decode=False):
+        """
+        read data from instance filesystem
+        remote_path: path in instance
+        decode: return as string
+        return_value: data as str or bytes
+        """
+        data = self.pylxd_container.files.get(remote_path)
+        return data.decode() if decode and isinstance(data, bytes) else data
+
+    def write_data(self, remote_path, data):
+        """
+        write data to instance filesystem
+        remote_path: path in instance
+        data: data to write, either str or bytes
+        """
+        self.pylxd_container.files.put(remote_path, data)
+
+    def console_log(self):
+        """
+        return_value: bytes of this instance’s console
+        """
+        raise NotImplementedError
+
+    def reboot(self, wait=True):
+        """
+        reboot instance
+        """
+        self.shutdown(wait=wait)
+        self.start(wait=wait)
+
+    def shutdown(self, wait=True):
+        """
+        shutdown instance
+        """
+        if self.pylxd_container.status != 'Stopped':
+            self.pylxd_container.stop(wait=wait)
+
+    def start(self, wait=True, wait_time=None):
+        """
+        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)
+
+    def freeze(self):
+        """
+        freeze instance
+        """
+        if self.pylxd_container.status != 'Frozen':
+            self.pylxd_container.freeze(wait=True)
+
+    def unfreeze(self):
+        """
+        unfreeze instance
+        """
+        if self.pylxd_container.status == 'Frozen':
+            self.pylxd_container.unfreeze(wait=True)
+
+    def destroy(self):
+        """
+        clean up instance
+        """
+        self.unfreeze()
+        self.shutdown()
+        self.pylxd_container.delete(wait=True)
+        if self.platform.container_exists(self.name):
+            raise OSError('container {} was not properly removed'
+                          .format(self.name))
+        super(LXDInstance, self).destroy()
diff --git a/tests/cloud_tests/manage.py b/tests/cloud_tests/manage.py
new file mode 100644
index 0000000..55d7dd9
--- /dev/null
+++ b/tests/cloud_tests/manage.py
@@ -0,0 +1,71 @@
+from tests.cloud_tests.config import VERIFY_EXT
+from tests.cloud_tests import (config, util)
+from tests.cloud_tests import TESTCASES_DIR
+
+import os
+import textwrap
+
+_verifier_fmt = textwrap.dedent(
+    """
+    \"\"\"cloud-init Integration Test Verify Script\"\"\"
+    from tests.cloud_tests.testcases import base
+
+
+    class {test_class}(base.CloudTestCase):
+        \"\"\"
+        Name: {test_name}
+        Category: {test_category}
+        Description: {test_description}
+        \"\"\"
+        pass
+    """
+).lstrip()
+_config_fmt = textwrap.dedent(
+    """
+    #
+    # Name: {test_name}
+    # Category: {test_category}
+    # Description: {test_description}
+    #
+    {config}
+    """
+).strip()
+
+
+def write_testcase_config(args, fmt_args, testcase_file):
+    """
+    write the testcase config file
+    """
+    testcase_config = {'enabled': args.enable, 'collect_scripts': {}}
+    if args.config:
+        testcase_config['cloud_config'] = args.config
+    fmt_args['config'] = util.yaml_format(testcase_config)
+    util.write_file(testcase_file, _config_fmt.format(**fmt_args), omode='w')
+
+
+def write_verifier(args, fmt_args, verifier_file):
+    """
+    write the verifier script
+    """
+    fmt_args['test_class'] = 'Test{}'.format(
+        config.name_sanatize(fmt_args['test_name']).title())
+    util.write_file(verifier_file, _verifier_fmt.format(**fmt_args), omode='w')
+
+
+def create(args):
+    """
+    create a new testcase
+    """
+    (test_category, test_name) = args.name.split('/')
+    fmt_args = {'test_name': test_name, 'test_category': test_category,
+                'test_description': str(args.description)}
+
+    testcase_file = config.name_to_path(args.name)
+    verifier_file = os.path.join(
+        TESTCASES_DIR, test_category,
+        config.name_sanatize(test_name) + VERIFY_EXT)
+
+    write_testcase_config(args, fmt_args, testcase_file)
+    write_verifier(args, fmt_args, verifier_file)
+
+    return 0
diff --git a/tests/cloud_tests/platforms.yaml b/tests/cloud_tests/platforms.yaml
new file mode 100644
index 0000000..ffed29a
--- /dev/null
+++ b/tests/cloud_tests/platforms.yaml
@@ -0,0 +1,15 @@
+# ============================= Platform Config ===============================
+default_platform_config:
+    # all disabled by default
+    enabled: false
+    # maximum time to retrieve image
+    get_image_timeout: 300
+    # maximum time to create instance (before waiting for cloud-init)
+    create_instance_timeout: 60
+
+platforms:
+    lxd:
+        enabled: true
+        get_image_timeout: 600
+    ec2: {}
+    azure: {}
diff --git a/tests/cloud_tests/platforms/__init__.py b/tests/cloud_tests/platforms/__init__.py
new file mode 100644
index 0000000..52e453f
--- /dev/null
+++ b/tests/cloud_tests/platforms/__init__.py
@@ -0,0 +1,15 @@
+from tests.cloud_tests.platforms import lxd
+
+PLATFORMS = {
+    'lxd': lxd.LXDPlatform,
+}
+
+
+def get_platform(platform_name, config):
+    """
+    Get the platform object for 'platform_name' and init
+    """
+    platform_cls = PLATFORMS.get(platform_name)
+    if not platform_cls:
+        raise ValueError('invalid platform name: {}'.format(platform_name))
+    return platform_cls(config)
diff --git a/tests/cloud_tests/platforms/base.py b/tests/cloud_tests/platforms/base.py
new file mode 100644
index 0000000..3679e85
--- /dev/null
+++ b/tests/cloud_tests/platforms/base.py
@@ -0,0 +1,48 @@
+class Platform(object):
+    """
+    Base class for platforms
+    """
+    platform_name = None
+
+    def __init__(self, config):
+        """
+        Set up platform
+        """
+        self.config = config
+
+    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
+
+        img_conf: configuration for image
+        return_value: cloud_tests.images instance
+        """
+        raise NotImplementedError
+
+    def destroy(self):
+        """
+        Clean up platform data
+        """
+        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
diff --git a/tests/cloud_tests/platforms/lxd.py b/tests/cloud_tests/platforms/lxd.py
new file mode 100644
index 0000000..414a162
--- /dev/null
+++ b/tests/cloud_tests/platforms/lxd.py
@@ -0,0 +1,93 @@
+from pylxd import (Client, exceptions)
+
+from tests.cloud_tests.images import lxd as lxd_image
+from tests.cloud_tests.instances import lxd as lxd_instance
+from tests.cloud_tests.platforms import base
+from tests.cloud_tests import util
+
+DEFAULT_SSTREAMS_SERVER = "https://images.linuxcontainers.org:8443";
+
+
+class LXDPlatform(base.Platform):
+    """
+    Lxd test platform
+    """
+    platform_name = 'lxd'
+
+    def __init__(self, config):
+        """
+        Set up platform
+        """
+        super(LXDPlatform, self).__init__(config)
+        # TODO: allow configuration of remote lxd host via env variables
+        # set up lxd connection
+        self.client = Client()
+
+    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
+        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)
+
+    def launch_container(self, image=None, container=None, ephemeral=False,
+                         config=None, block=True,
+                         image_desc=None, use_desc=None):
+        """
+        launch a container
+        image: image fingerprint to launch from
+        container: container to copy
+        ephemeral: delete image after first shutdown
+        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
+        return_value: cloud_tests.instances instance
+        """
+        if not (image or container):
+            raise ValueError("either image or container must be specified")
+        container = self.client.containers.create({
+            'name': util.gen_instance_name(image_desc=image_desc,
+                                           use_desc=use_desc,
+                                           used_list=self.list_containers()),
+            'ephemeral': bool(ephemeral),
+            'config': config if isinstance(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)
+
+    def container_exists(self, container_name):
+        """
+        check if container with name 'container_name' exists
+        return_value: True if exists else False
+        """
+        res = True
+        try:
+            self.client.containers.get(container_name)
+        except exceptions.LXDAPIException as e:
+            res = False
+            if e.response.status_code != 404:
+                raise
+        return res
+
+    def list_containers(self):
+        """
+        list names of all containers
+        return_value: list of names
+        """
+        return [container.name for container in self.client.containers.all()]
+
+    def destroy(self):
+        """
+        Clean up platform data
+        """
+        super(LXDPlatform, self).destroy()
diff --git a/tests/cloud_tests/releases.yaml b/tests/cloud_tests/releases.yaml
new file mode 100644
index 0000000..a3a6e4d
--- /dev/null
+++ b/tests/cloud_tests/releases.yaml
@@ -0,0 +1,77 @@
+# ============================= 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
+
+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
+    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
+    sid:
+        platform_ident:
+            lxd:
+                alias: debian/sid/default
+    stretch:
+        platform_ident:
+            lxd:
+                alias: debian/stretch/default
+    wheezy:
+        platform_ident:
+            lxd:
+                alias: debian/wheezy/default
+    centos70:
+        timeout: 180
+        platform_ident:
+            lxd:
+                alias: centos/7/default
+    centos66:
+        timeout: 180
+        platform_ident:
+            lxd:
+                alias: centos/6/default
diff --git a/tests/cloud_tests/setup_image.py b/tests/cloud_tests/setup_image.py
new file mode 100644
index 0000000..488c7fa
--- /dev/null
+++ b/tests/cloud_tests/setup_image.py
@@ -0,0 +1,191 @@
+from tests.cloud_tests import LOG
+from tests.cloud_tests import stage, util
+
+from functools import partial
+import os
+
+
+def install_deb(args, image):
+    """
+    install deb into image
+    args: cmdline arguments, must contain --deb
+    image: cloud_tests.images instance to operate on
+    return_value: None, may raise errors
+    """
+    # ensure system is compatible with package format
+    os_family = util.get_os_family(image.properties['os'])
+    if os_family != 'debian':
+        raise NotImplementedError('install deb: {} not supported on os '
+                                  'family: {}'.format(args.deb, os_family))
+
+    # install deb
+    LOG.debug('installing deb: %s into target', args.deb)
+    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))
+
+    # 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()
+    if expected_version != found_version:
+        raise OSError('install deb version "{}" does not match expected "{}"'
+                      .format(found_version, expected_version))
+
+    LOG.debug('successfully installed: %s, version: %s', args.deb,
+              found_version)
+
+
+def install_rpm(args, image):
+    """
+    install rpm into image
+    args: cmdline arguments, must contain --rpm
+    image: cloud_tests.images instance to operate on
+    return_value: None, may raise errors
+    """
+    # ensure system is compatible with package format
+    os_family = util.get_os_family(image.properties['os'])
+    if os_family not in ['redhat', 'sles']:
+        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)
+    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))
+
+    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()
+    if expected_version != found_version:
+        raise OSError('install rpm version "{}" does not match expected "{}"'
+                      .format(found_version, expected_version))
+
+    LOG.debug('successfully installed: %s, version %s', args.rpm,
+              found_version)
+
+
+def upgrade(args, image):
+    """
+    run the system's 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'
+    elif os_family == 'redhat':
+        cmd = 'yum upgrade --assumeyes'
+    else:
+        raise NotImplementedError('upgrade command not configured for distro '
+                                  '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))
+
+
+def run_script(args, image):
+    """
+    run a script in the target image
+    args: cmdline arguments, must contain --script
+    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)
+
+
+def enable_ppa(args, image):
+    """
+    enable a ppa in the target image
+    args: cmdline arguments, must contain --ppa
+    image: cloud_tests.image instance to operate on
+    return_value: None, may raise errors
+    """
+    # ppa only supported on ubuntu (maybe debian?)
+    if image.properties['os'] != '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)
+    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))
+
+
+def enable_repo(args, image):
+    """
+    enable a repository in the target image
+    args: cmdline arguments, must contain --repo
+    image: cloud_tests.image instance to operate on
+    return_value: None, may raise errors
+    """
+    # find enable repo command for the distro
+    os_family = util.get_os_family(image.properties['os'])
+    if os_family == 'debian':
+        cmd = ('echo "{}" >> "/etc/apt/sources.list" '.format(args.repo) +
+               '&& apt-get update')
+    elif os_family == 'centos':
+        cmd = 'yum-config-manager --add-repo="{}"'.format(args.repo)
+    else:
+        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))
+
+
+def setup_image(args, image):
+    """
+    set up image as specified in args
+    args: cmdline arguments
+    image: cloud_tests.image instance to operate on
+    return_value: tuple of results and fail count
+    """
+    # 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
+    handlers = (
+        # arg   handler     description
+        ('deb', install_deb, 'setup func for --deb, install deb'),
+        ('rpm', install_rpm, 'setup func for --rpm, install rpm'),
+        ('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'),
+    )
+
+    # 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)
diff --git a/tests/cloud_tests/snapshots/__init__.py b/tests/cloud_tests/snapshots/__init__.py
new file mode 100644
index 0000000..328275c
--- /dev/null
+++ b/tests/cloud_tests/snapshots/__init__.py
@@ -0,0 +1,5 @@
+def get_snapshot(image):
+    """
+    get snapshot from image
+    """
+    return image.snapshot()
diff --git a/tests/cloud_tests/snapshots/base.py b/tests/cloud_tests/snapshots/base.py
new file mode 100644
index 0000000..5c4a6ce
--- /dev/null
+++ b/tests/cloud_tests/snapshots/base.py
@@ -0,0 +1,39 @@
+class Snapshot(object):
+    """
+    Base class for snapshots
+    """
+    platform_name = None
+
+    def __init__(self, properties, config):
+        """
+        Set up snapshot
+        """
+        self.properties = properties
+        self.config = config
+
+    def __str__(self):
+        """
+        a brief description of the snapshot
+        """
+        return '-'.join((self.properties['os'], self.properties['release']))
+
+    def launch(self, user_data, meta_data=None, block=True, start=True,
+               use_desc=None):
+        """
+        launch instance
+
+        user_data: user-data for the instance
+        instance_id: instance-id for the instance
+        block: wait until instance is created
+        start: start instance and wait until fully started
+        use_desc: description of snapshot instance use
+
+        return_value: an Instance
+        """
+        raise NotImplementedError
+
+    def destroy(self):
+        """
+        Clean up snapshot data
+        """
+        pass
diff --git a/tests/cloud_tests/snapshots/lxd.py b/tests/cloud_tests/snapshots/lxd.py
new file mode 100644
index 0000000..dd71b20
--- /dev/null
+++ b/tests/cloud_tests/snapshots/lxd.py
@@ -0,0 +1,46 @@
+from tests.cloud_tests.snapshots import base
+
+
+class LXDSnapshot(base.Snapshot):
+    """
+    LXD image copy backed snapshot
+    """
+    platform_name = "lxd"
+
+    def __init__(self, properties, config, platform, pylxd_frozen_instance):
+        """
+        Set up snapshot
+        """
+        self.platform = platform
+        self.pylxd_frozen_instance = pylxd_frozen_instance
+        super(LXDSnapshot, self).__init__(properties, config)
+
+    def launch(self, user_data, meta_data=None, block=True, start=True,
+               use_desc=None):
+        """
+        launch instance
+
+        user_data: user-data for the instance
+        instance_id: instance-id for the instance
+        block: wait until instance is created
+        start: start instance and wait until fully started
+        use_desc: description of snapshot instance use
+
+        return_value: an Instance
+        """
+        inst_config = {'user.user-data': user_data}
+        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)
+        if start:
+            instance.start(wait=True, wait_time=self.config.get('timeout'))
+        return instance
+
+    def destroy(self):
+        """
+        Clean up snapshot data
+        """
+        self.pylxd_frozen_instance.destroy()
+        super(LXDSnapshot, self).destroy()
diff --git a/tests/cloud_tests/stage.py b/tests/cloud_tests/stage.py
new file mode 100644
index 0000000..2cf7f82
--- /dev/null
+++ b/tests/cloud_tests/stage.py
@@ -0,0 +1,109 @@
+import sys
+import time
+import traceback
+
+from tests.cloud_tests import LOG
+
+
+class PlatformComponent(object):
+    """
+    context manager to safely handle platform components, ensuring that
+    .destroy() is called
+    """
+
+    def __init__(self, get_func):
+        """
+        store get_<platform component> function as partial taking no args
+        """
+        self.get_func = get_func
+
+    def __enter__(self):
+        """
+        create instance of platform component
+        """
+        self.instance = self.get_func()
+        return self.instance
+
+    def __exit__(self, etype, value, trace):
+        """
+        destroy instance
+        """
+        if self.instance is not None:
+            self.instance.destroy()
+
+
+def run_single(name, call):
+    """
+    run a single function, keeping track of results and failures and time
+    name: name of part
+    call: call to make
+    return_value: a tuple of result and fail count
+    """
+    res = {
+        'name': name,
+        'time': 0,
+        'errors': [],
+        'success': False
+    }
+    failed = 0
+    start_time = time.time()
+
+    try:
+        call()
+    except Exception as e:
+        failed += 1
+        res['errors'].append(str(e))
+        LOG.error('stage part: %s encountered error: %s', name, str(e))
+        trace = traceback.extract_tb(sys.exc_info()[-1])
+        LOG.error('traceback:\n%s', ''.join(traceback.format_list(trace)))
+
+    res['time'] = time.time() - start_time
+    if failed == 0:
+        res['success'] = True
+
+    return res, failed
+
+
+def run_stage(parent_name, calls, continue_after_error=True):
+    """
+    run a stage of collection, keeping track of results and failures
+    parent_name: name of stage calls are under
+    calls: list of function call taking no params. must return a tuple
+           of results and failures. may raise exceptions
+    continue_after_error: whether or not to proceed to the next call after
+                          catching an exception or recording a failure
+    return_value: a tuple of results and failures, with result containing
+                  results from the function call under 'stages', and a list
+                  of errors (if any on this level), and elapsed time
+                  running stage, and the name
+    """
+    res = {
+        'name': parent_name,
+        'time': 0,
+        'errors': [],
+        'stages': [],
+        'success': False,
+    }
+    failed = 0
+    start_time = time.time()
+
+    for call in calls:
+        try:
+            (call_res, call_failed) = call()
+            res['stages'].append(call_res)
+        except Exception as e:
+            call_failed = 1
+            res['errors'].append(str(e))
+            LOG.error('stage: %s encountered error: %s', parent_name, str(e))
+            trace = traceback.extract_tb(sys.exc_info()[-1])
+            LOG.error('traceback:\n%s', ''.join(traceback.format_list(trace)))
+
+        failed += call_failed
+        if call_failed and not continue_after_error:
+            break
+
+    res['time'] = time.time() - start_time
+    if not failed:
+        res['success'] = True
+
+    return (res, failed)
diff --git a/tests/cloud_tests/testcases.yaml b/tests/cloud_tests/testcases.yaml
new file mode 100644
index 0000000..02701bd
--- /dev/null
+++ b/tests/cloud_tests/testcases.yaml
@@ -0,0 +1,25 @@
+# ============================= Base Test Config ==============================
+base_test_data:
+    script_timeout: 20
+    enabled: True
+    cloud_config: |
+        #cloud-config
+    collect_scripts:
+        cloud-init.log: |
+            #!/bin/bash
+            cat /var/log/cloud-init.log
+        cloud-init-output.log: |
+            #!/bin/bash
+            cat /var/log/cloud-init-output.log
+        instance-id: |
+            #!/bin/bash
+            cat /run/cloud-init/.instance-id
+        result.json: |
+            #!/bin/bash
+            cat /run/cloud-init/result.json
+        status.json: |
+            #!/bin/bash
+            cat /run/cloud-init/status.json
+        cloud-init-version: |
+            #!/bin/bash
+            dpkg-query -W -f='${Version}' cloud-init
diff --git a/tests/cloud_tests/testcases/__init__.py b/tests/cloud_tests/testcases/__init__.py
new file mode 100644
index 0000000..f5cb0fd
--- /dev/null
+++ b/tests/cloud_tests/testcases/__init__.py
@@ -0,0 +1,43 @@
+import importlib
+import inspect
+import unittest
+
+from tests.cloud_tests import config
+from tests.cloud_tests.testcases.base import CloudTestCase as base_test
+
+
+def discover_tests(test_name):
+    """
+    discover tests in test file for 'testname'
+    return_value: list of test classes
+    """
+    testmod_name = 'tests.cloud_tests.testcases.{}'.format(
+        config.name_sanatize(test_name))
+    try:
+        testmod = importlib.import_module(testmod_name)
+    except NameError:
+        raise ValueError('no test verifier found at: {}'.format(testmod_name))
+
+    return [mod for name, mod in inspect.getmembers(testmod)
+            if inspect.isclass(mod) and base_test in mod.__bases__ and
+            getattr(mod, '__test__', True)]
+
+
+def get_suite(test_name, data, conf):
+    """
+    get test suite with all tests for 'testname'
+    return_value: a test suite
+    """
+    suite = unittest.TestSuite()
+    for test_class in discover_tests(test_name):
+
+        class tmp(test_class):
+
+            @classmethod
+            def setUpClass(cls):
+                cls.data = data
+                cls.conf = conf
+
+        suite.addTest(unittest.defaultTestLoader.loadTestsFromTestCase(tmp))
+
+    return suite
diff --git a/tests/cloud_tests/testcases/base.py b/tests/cloud_tests/testcases/base.py
new file mode 100644
index 0000000..6d6c282
--- /dev/null
+++ b/tests/cloud_tests/testcases/base.py
@@ -0,0 +1,77 @@
+from cloudinit import util as c_util
+
+import json
+import unittest
+
+
+class CloudTestCase(unittest.TestCase):
+    """
+    base test class for verifiers
+    """
+    data = None
+    conf = None
+    _cloud_config = None
+
+    @property
+    def cloud_config(self):
+        """
+        get the cloud-config used by the test
+        """
+        if not self._cloud_config:
+            self._cloud_config = c_util.load_yaml(self.conf)
+        return self._cloud_config
+
+    def get_config_entry(self, name):
+        """
+        get a config entry from cloud-config ensuring that it is present
+        """
+        if name not in self.cloud_config:
+            raise AssertionError('Key "{}" not in cloud config'.format(name))
+        return self.cloud_config[name]
+
+    def get_data_file(self, name):
+        """
+        get data file failing test if it is not present
+        """
+        if name not in self.data:
+            raise AssertionError('File "{}" missing from collect data'
+                                 .format(name))
+        return self.data[name]
+
+    def get_instance_id(self):
+        """
+        get recorded instance id
+        """
+        return self.get_data_file('instance-id').strip()
+
+    def get_status_data(self, data, version=None):
+        """
+        parse result.json and status.json like data files
+        data: data to load
+        version: cloud-init output version, defaults to 'v1'
+        return_value: dict of data or None if missing
+        """
+        if not version:
+            version = 'v1'
+        data = json.loads(data)
+        return data.get(version)
+
+    def get_datasource(self):
+        """
+        get datasource name
+        """
+        data = self.get_status_data(self.get_data_file('result.json'))
+        return data.get('datasource')
+
+    def test_no_stages_errors(self):
+        """
+        ensure that there were no errors in any stage
+        """
+        status = self.get_status_data(self.get_data_file('status.json'))
+        for stage in ('init', 'init-local', 'modules-config', 'modules-final'):
+            self.assertIn(stage, status)
+            self.assertEqual(len(status[stage]['errors']), 0,
+                             'errors {} were encountered in stage {}'
+                             .format(status[stage]['errors'], stage))
+        result = self.get_status_data(self.get_data_file('result.json'))
+        self.assertEqual(len(result['errors']), 0)
diff --git a/tests/cloud_tests/testcases/bugs/__init__.py b/tests/cloud_tests/testcases/bugs/__init__.py
new file mode 100644
index 0000000..6c078a5
--- /dev/null
+++ b/tests/cloud_tests/testcases/bugs/__init__.py
@@ -0,0 +1,4 @@
+"""
+Test verifiers for cloud-init bugs
+See configs/bugs/README.md for more information
+"""
diff --git a/tests/cloud_tests/testcases/bugs/lp1511485.py b/tests/cloud_tests/testcases/bugs/lp1511485.py
new file mode 100644
index 0000000..962215b
--- /dev/null
+++ b/tests/cloud_tests/testcases/bugs/lp1511485.py
@@ -0,0 +1,11 @@
+"""cloud-init Integration Test Verify Script"""
+from tests.cloud_tests.testcases import base
+
+
+class TestLP1511485(base.CloudTestCase):
+    """Test LP# 1511485"""
+
+    def test_final_message(self):
+        """Test final message exists"""
+        out = self.get_data_file('cloud-init-output.log')
+        self.assertIn('Final message from cloud-config', out)
diff --git a/tests/cloud_tests/testcases/bugs/lp1628337.py b/tests/cloud_tests/testcases/bugs/lp1628337.py
new file mode 100644
index 0000000..f9ae7ae
--- /dev/null
+++ b/tests/cloud_tests/testcases/bugs/lp1628337.py
@@ -0,0 +1,19 @@
+"""cloud-init Integration Test Verify Script"""
+from tests.cloud_tests.testcases import base
+
+
+class TestLP1628337(base.CloudTestCase):
+    """Test LP# 1511485"""
+
+    def test_fetch_indices(self):
+        """Verify no apt errors"""
+        out = self.get_data_file('cloud-init-output.log')
+        self.assertNotIn('W: Failed to fetch', out)
+        self.assertNotIn('W: Some index files failed to download. '
+                         'They have been ignored, or old ones used instead.',
+                         out)
+
+    def test_ntp(self):
+        """Verify can find ntp and install it"""
+        out = self.get_data_file('cloud-init-output.log')
+        self.assertNotIn('E: Unable to locate package ntp', out)
diff --git a/tests/cloud_tests/testcases/examples/__init__.py b/tests/cloud_tests/testcases/examples/__init__.py
new file mode 100644
index 0000000..8d047ee
--- /dev/null
+++ b/tests/cloud_tests/testcases/examples/__init__.py
@@ -0,0 +1,4 @@
+"""
+Test verifiers for cloud-init examples
+See configs/examples/README.md for more information
+"""
diff --git a/tests/cloud_tests/testcases/examples/add_apt_repositories.py b/tests/cloud_tests/testcases/examples/add_apt_repositories.py
new file mode 100644
index 0000000..f45eaa7
--- /dev/null
+++ b/tests/cloud_tests/testcases/examples/add_apt_repositories.py
@@ -0,0 +1,16 @@
+"""cloud-init Integration Test Verify Script"""
+from tests.cloud_tests.testcases import base
+
+
+class TestAptconfigurePrimary(base.CloudTestCase):
+    """Example cloud-config test"""
+
+    def test_ubuntu_sources(self):
+        """Test no default Ubuntu entries exist"""
+        out = self.get_data_file('ubuntu.sources.list')
+        self.assertEqual(0, int(out))
+
+    def test_gatech_sources(self):
+        """Test GaTech entires exist"""
+        out = self.get_data_file('gatech.sources.list')
+        self.assertEqual(20, int(out))
diff --git a/tests/cloud_tests/testcases/examples/alter_completion_message.py b/tests/cloud_tests/testcases/examples/alter_completion_message.py
new file mode 100644
index 0000000..21e3536
--- /dev/null
+++ b/tests/cloud_tests/testcases/examples/alter_completion_message.py
@@ -0,0 +1,45 @@
+"""cloud-init Integration Test Verify Script"""
+from tests.cloud_tests.testcases import base
+
+
+class TestFinalMessage(base.CloudTestCase):
+    """
+    test cloud init module `cc_final_message`
+    """
+    subs_char = '$'
+
+    def get_final_message_config(self):
+        """
+        get config for final message
+        """
+        self.assertIn('final_message', self.cloud_config)
+        return self.cloud_config['final_message']
+
+    def get_final_message(self):
+        """
+        get final message from log
+        """
+        out = self.get_data_file('cloud-init-output.log')
+        lines = len(self.get_final_message_config().splitlines())
+        return '\n'.join(out.splitlines()[-1 * lines:])
+
+    def test_final_message_string(self):
+        """
+        ensure final handles regular strings
+        """
+        for actual, config in zip(
+                self.get_final_message().splitlines(),
+                self.get_final_message_config().splitlines()):
+            if self.subs_char not in config:
+                self.assertEqual(actual, config)
+
+    def test_final_message_subs(self):
+        """
+        test variable substitution in final message
+        """
+        # TODO: add verification of other substitutions
+        patterns = {'$datasource': self.get_datasource()}
+        for key, expected in patterns.items():
+            index = self.get_final_message_config().splitlines().index(key)
+            actual = self.get_final_message().splitlines()[index]
+            self.assertEqual(actual, expected)
diff --git a/tests/cloud_tests/testcases/examples/configure_instance_trusted_ca_certificates.py b/tests/cloud_tests/testcases/examples/configure_instance_trusted_ca_certificates.py
new file mode 100644
index 0000000..c50277c
--- /dev/null
+++ b/tests/cloud_tests/testcases/examples/configure_instance_trusted_ca_certificates.py
@@ -0,0 +1,23 @@
+"""cloud-init Integration Test Verify Script"""
+from tests.cloud_tests.testcases import base
+
+
+class TestTrustedCA(base.CloudTestCase):
+    """Example cloud-config test"""
+
+    def test_cert_count_ca(self):
+        """Test correct count of CAs in .crt"""
+        out = self.get_data_file('cert_count_ca')
+        self.assertIn('7 /etc/ssl/certs/ca-certificates.crt', out)
+
+    def test_cert_count_cloudinit(self):
+        """Test correct count of CAs in .pem"""
+        out = self.get_data_file('cert_count_cloudinit')
+        self.assertIn('7 /etc/ssl/certs/cloud-init-ca-certs.pem', out)
+
+    def test_cloudinit_certs(self):
+        """Test text of cert"""
+        out = self.get_data_file('cloudinit_certs')
+        self.assertIn('-----BEGIN CERTIFICATE-----', out)
+        self.assertIn('YOUR-ORGS-TRUSTED-CA-CERT-HERE', out)
+        self.assertIn('-----END CERTIFICATE-----', out)
diff --git a/tests/cloud_tests/testcases/examples/configure_instances_ssh_keys.py b/tests/cloud_tests/testcases/examples/configure_instances_ssh_keys.py
new file mode 100644
index 0000000..f5afbe7
--- /dev/null
+++ b/tests/cloud_tests/testcases/examples/configure_instances_ssh_keys.py
@@ -0,0 +1,27 @@
+"""cloud-init Integration Test Verify Script"""
+from tests.cloud_tests.testcases import base
+
+
+class TestSSHKeys(base.CloudTestCase):
+    """Example cloud-config test"""
+
+    def test_cert_count(self):
+        """Test cert count"""
+        out = self.get_data_file('cert_count')
+        self.assertEqual(20, int(out))
+
+    def test_dsa_public(self):
+        """Test DSA key has ending"""
+        out = self.get_data_file('dsa_public')
+        self.assertIn('ZN4XnifuO5krqAybngIy66PMEoQ= smoser@localhost', out)
+
+    def test_rsa_public(self):
+        """Test RSA key has specific ending"""
+        out = self.get_data_file('rsa_public')
+        self.assertIn('PemAWthxHO18QJvWPocKJtlsDNi3 smoser@localhost', out)
+
+    def test_auth_keys(self):
+        """Test authorized keys has specific ending"""
+        out = self.get_data_file('auth_keys')
+        self.assertIn('QPOt5Q8zWd9qG7PBl9+eiH5qV7NZ mykey@host', out)
+        self.assertIn('Hj29SCmXp5Kt5/82cD/VN3NtHw== smoser@brickies', out)
diff --git a/tests/cloud_tests/testcases/examples/including_user_groups.py b/tests/cloud_tests/testcases/examples/including_user_groups.py
new file mode 100644
index 0000000..704f699
--- /dev/null
+++ b/tests/cloud_tests/testcases/examples/including_user_groups.py
@@ -0,0 +1,39 @@
+"""cloud-init Integration Test Verify Script"""
+from tests.cloud_tests.testcases import base
+
+
+class TestUserGroups(base.CloudTestCase):
+    """Example cloud-config test"""
+
+    def test_group_ubuntu(self):
+        """Test ubuntu group exists"""
+        out = self.get_data_file('group_ubuntu')
+        self.assertRegex(out, r'ubuntu:x:[0-9]{4}:')
+
+    def test_group_cloud_users(self):
+        """Test cloud users group exists"""
+        out = self.get_data_file('group_cloud_users')
+        self.assertRegex(out, r'cloud-users:x:[0-9]{4}:barfoo')
+
+    def test_user_ubuntu(self):
+        """Test ubuntu user exists"""
+        out = self.get_data_file('user_ubuntu')
+        self.assertRegex(
+            out, r'ubuntu:x:[0-9]{4}:[0-9]{4}:Ubuntu:/home/ubuntu:/bin/bash')
+
+    def test_user_foobar(self):
+        """Test foobar user exists"""
+        out = self.get_data_file('user_foobar')
+        self.assertRegex(
+            out, r'foobar:x:[0-9]{4}:[0-9]{4}:Foo B. Bar:/home/foobar:')
+
+    def test_user_barfoo(self):
+        """Test barfoo user exists"""
+        out = self.get_data_file('user_barfoo')
+        self.assertRegex(
+            out, r'barfoo:x:[0-9]{4}:[0-9]{4}:Bar B. Foo:/home/barfoo:')
+
+    def test_user_cloudy(self):
+        """Test cloudy user exists"""
+        out = self.get_data_file('user_cloudy')
+        self.assertRegex(out, r'cloudy:x:[0-9]{3,4}:')
diff --git a/tests/cloud_tests/testcases/examples/install_arbitrary_packages.py b/tests/cloud_tests/testcases/examples/install_arbitrary_packages.py
new file mode 100644
index 0000000..08599be
--- /dev/null
+++ b/tests/cloud_tests/testcases/examples/install_arbitrary_packages.py
@@ -0,0 +1,16 @@
+"""cloud-init Integration Test Verify Script"""
+from tests.cloud_tests.testcases import base
+
+
+class TestInstall(base.CloudTestCase):
+    """Example cloud-config test"""
+
+    def test_htop(self):
+        """Verify htop installed"""
+        out = self.get_data_file('htop')
+        self.assertEqual(1, int(out))
+
+    def test_tree(self):
+        """Verify tree installed"""
+        out = self.get_data_file('treeutils')
+        self.assertEqual(1, int(out))
diff --git a/tests/cloud_tests/testcases/examples/run_apt_upgrade.py b/tests/cloud_tests/testcases/examples/run_apt_upgrade.py
new file mode 100644
index 0000000..6c07e32
--- /dev/null
+++ b/tests/cloud_tests/testcases/examples/run_apt_upgrade.py
@@ -0,0 +1,15 @@
+"""cloud-init Integration Test Verify Script"""
+from tests.cloud_tests.testcases import base
+
+
+class TestUpgrade(base.CloudTestCase):
+    """Example cloud-config test"""
+
+    def test_upgrade(self):
+        """Test upgrade exists in apt history"""
+        out = self.get_data_file('cloud-init.log')
+        self.assertIn(
+            '[CLOUDINIT] util.py[DEBUG]: apt-upgrade '
+            '[eatmydata apt-get --option=Dpkg::Options::=--force-confold '
+            '--option=Dpkg::options::=--force-unsafe-io --assume-yes --quiet '
+            'dist-upgrade] took', out)
diff --git a/tests/cloud_tests/testcases/examples/run_commands.py b/tests/cloud_tests/testcases/examples/run_commands.py
new file mode 100644
index 0000000..b4d311f
--- /dev/null
+++ b/tests/cloud_tests/testcases/examples/run_commands.py
@@ -0,0 +1,11 @@
+"""cloud-init Integration Test Verify Script"""
+from tests.cloud_tests.testcases import base
+
+
+class TestRunCmd(base.CloudTestCase):
+    """Example cloud-config test"""
+
+    def test_run_cmd(self):
+        """Test run command worked"""
+        out = self.get_data_file('run_cmd')
+        self.assertIn('cloud-init run cmd test', out)
diff --git a/tests/cloud_tests/testcases/examples/run_commands_first_boot.py b/tests/cloud_tests/testcases/examples/run_commands_first_boot.py
new file mode 100644
index 0000000..a7577ae
--- /dev/null
+++ b/tests/cloud_tests/testcases/examples/run_commands_first_boot.py
@@ -0,0 +1,11 @@
+"""cloud-init Integration Test Verify Script"""
+from tests.cloud_tests.testcases import base
+
+
+class TestBootCmd(base.CloudTestCase):
+    """Example cloud-config test"""
+
+    def test_bootcmd_host(self):
+        """Test boot command worked"""
+        out = self.get_data_file('hosts')
+        self.assertIn('192.168.1.130 us.archive.ubuntu.com', out)
diff --git a/tests/cloud_tests/testcases/examples/writing_out_arbitrary_files.py b/tests/cloud_tests/testcases/examples/writing_out_arbitrary_files.py
new file mode 100644
index 0000000..97b6d2c
--- /dev/null
+++ b/tests/cloud_tests/testcases/examples/writing_out_arbitrary_files.py
@@ -0,0 +1,26 @@
+"""cloud-init Integration Test Verify Script"""
+from tests.cloud_tests.testcases import base
+
+
+class TestWriteFiles(base.CloudTestCase):
+    """Example cloud-config test"""
+
+    def test_b64(self):
+        """Test b64 encoded file reads as ascii"""
+        out = self.get_data_file('file_b64')
+        self.assertIn('ASCII text', out)
+
+    def test_binary(self):
+        """Test binary file reads as executable"""
+        out = self.get_data_file('file_binary')
+        self.assertIn('ELF 64-bit LSB executable, x86-64, version 1', out)
+
+    def test_gzip(self):
+        """Test gzip file shows up as a shell script"""
+        out = self.get_data_file('file_gzip')
+        self.assertIn('POSIX shell script, ASCII text executable', out)
+
+    def test_text(self):
+        """Test text shows up as ASCII text"""
+        out = self.get_data_file('file_text')
+        self.assertIn('ASCII text', out)
diff --git a/tests/cloud_tests/testcases/main/__init__.py b/tests/cloud_tests/testcases/main/__init__.py
new file mode 100644
index 0000000..460550a
--- /dev/null
+++ b/tests/cloud_tests/testcases/main/__init__.py
@@ -0,0 +1,4 @@
+"""
+Test verifiers for cloud-init main features
+See configs/main/README.md for more information
+"""
diff --git a/tests/cloud_tests/testcases/main/command_output_simple.py b/tests/cloud_tests/testcases/main/command_output_simple.py
new file mode 100644
index 0000000..281f221
--- /dev/null
+++ b/tests/cloud_tests/testcases/main/command_output_simple.py
@@ -0,0 +1,17 @@
+from tests.cloud_tests.testcases import base
+
+
+class TestCommandOutputSimple(base.CloudTestCase):
+    """
+    test functionality of simple output redirection
+    """
+
+    def test_output_file(self):
+        """
+        ensure that the output file is not empty and has all stages
+        """
+        data = self.get_data_file('cloud-init-test-output')
+        self.assertNotEqual(len(data), 0, "specified log empty")
+        self.assertEqual(self.get_config_entry('final_message'),
+                         data.splitlines()[-1].strip())
+        # TODO: need to test that all stages redirected here
diff --git a/tests/cloud_tests/testcases/modules/__init__.py b/tests/cloud_tests/testcases/modules/__init__.py
new file mode 100644
index 0000000..d01fa9e
--- /dev/null
+++ b/tests/cloud_tests/testcases/modules/__init__.py
@@ -0,0 +1,4 @@
+"""
+Test verifiers for cloud-init cc modules
+See configs/modules/README.md for more information
+"""
diff --git a/tests/cloud_tests/testcases/modules/apt_configure_conf.py b/tests/cloud_tests/testcases/modules/apt_configure_conf.py
new file mode 100644
index 0000000..06022f1
--- /dev/null
+++ b/tests/cloud_tests/testcases/modules/apt_configure_conf.py
@@ -0,0 +1,16 @@
+"""cloud-init Integration Test Verify Script"""
+from tests.cloud_tests.testcases import base
+
+
+class TestAptconfigureConf(base.CloudTestCase):
+    """Test apt-configure module"""
+
+    def test_apt_conf_assumeyes(self):
+        """Test config assumes true"""
+        out = self.get_data_file('94cloud-init-config')
+        self.assertIn('Assume-Yes "true";', out)
+
+    def test_apt_conf_fixbroken(self):
+        """Test config fixes broken"""
+        out = self.get_data_file('94cloud-init-config')
+        self.assertIn('Fix-Broken "true";', out)
diff --git a/tests/cloud_tests/testcases/modules/apt_configure_disable_suites.py b/tests/cloud_tests/testcases/modules/apt_configure_disable_suites.py
new file mode 100644
index 0000000..e5fe979
--- /dev/null
+++ b/tests/cloud_tests/testcases/modules/apt_configure_disable_suites.py
@@ -0,0 +1,11 @@
+"""cloud-init Integration Test Verify Script"""
+from tests.cloud_tests.testcases import base
+
+
+class TestAptconfigureDisableSuites(base.CloudTestCase):
+    """Test apt-configure module"""
+
+    def test_empty_sourcelist(self):
+        """Test source list is empty"""
+        out = self.get_data_file('sources.list')
+        self.assertEqual('', out)
diff --git a/tests/cloud_tests/testcases/modules/apt_configure_primary.py b/tests/cloud_tests/testcases/modules/apt_configure_primary.py
new file mode 100644
index 0000000..4ec730b
--- /dev/null
+++ b/tests/cloud_tests/testcases/modules/apt_configure_primary.py
@@ -0,0 +1,16 @@
+"""cloud-init Integration Test Verify Script"""
+from tests.cloud_tests.testcases import base
+
+
+class TestAptconfigurePrimary(base.CloudTestCase):
+    """Test apt-configure module"""
+
+    def test_ubuntu_sources(self):
+        """Test no default Ubuntu entries exist"""
+        out = self.get_data_file('ubuntu.sources.list')
+        self.assertEqual(0, int(out))
+
+    def test_gatech_sources(self):
+        """Test GaTech entires exist"""
+        out = self.get_data_file('gatech.sources.list')
+        self.assertEqual(20, int(out))
diff --git a/tests/cloud_tests/testcases/modules/apt_configure_proxy.py b/tests/cloud_tests/testcases/modules/apt_configure_proxy.py
new file mode 100644
index 0000000..3079b9f
--- /dev/null
+++ b/tests/cloud_tests/testcases/modules/apt_configure_proxy.py
@@ -0,0 +1,18 @@
+"""cloud-init Integration Test Verify Script"""
+from tests.cloud_tests.testcases import base
+
+
+class TestAptconfigureProxy(base.CloudTestCase):
+    """Test apt-configure module"""
+
+    def test_proxy_config(self):
+        """Test proxy options added to apt config"""
+        out = self.get_data_file('90cloud-init-aptproxy')
+        self.assertIn(
+            'Acquire::http::Proxy "http://squid.internal:3128";;', out)
+        self.assertIn(
+            'Acquire::http::Proxy "http://squid.internal:3128";;', out)
+        self.assertIn(
+            'Acquire::ftp::Proxy "ftp://squid.internal:3128";;', out)
+        self.assertIn(
+            'Acquire::https::Proxy "https://squid.internal:3128";;', out)
diff --git a/tests/cloud_tests/testcases/modules/apt_configure_security.py b/tests/cloud_tests/testcases/modules/apt_configure_security.py
new file mode 100644
index 0000000..a74d07f
--- /dev/null
+++ b/tests/cloud_tests/testcases/modules/apt_configure_security.py
@@ -0,0 +1,11 @@
+"""cloud-init Integration Test Verify Script"""
+from tests.cloud_tests.testcases import base
+
+
+class TestAptconfigureSecurity(base.CloudTestCase):
+    """Test apt-configure module"""
+
+    def test_security_mirror(self):
+        """Test security lines added and uncommented in source.list"""
+        out = self.get_data_file('sources.list')
+        self.assertEqual(6, int(out))
diff --git a/tests/cloud_tests/testcases/modules/apt_configure_sources_key.py b/tests/cloud_tests/testcases/modules/apt_configure_sources_key.py
new file mode 100644
index 0000000..3c55066
--- /dev/null
+++ b/tests/cloud_tests/testcases/modules/apt_configure_sources_key.py
@@ -0,0 +1,19 @@
+"""cloud-init Integration Test Verify Script"""
+from tests.cloud_tests.testcases import base
+
+
+class TestAptconfigureSourcesKey(base.CloudTestCase):
+    """Test apt-configure module"""
+
+    def test_apt_key_list(self):
+        """Test key list updated"""
+        out = self.get_data_file('apt_key_list')
+        self.assertIn(
+            'E3A0 9FF8 3A2B 6158 6C9D  DB78 2257 4F06 10CB 188A', out)
+        self.assertIn('Launchpad PPA for Joshua Powers', out)
+
+    def test_source_list(self):
+        """Test source.list updated"""
+        out = self.get_data_file('sources.list')
+        self.assertIn(
+            'http://ppa.launchpad.net/powersj/debootstrap-1636583/ubuntu', out)
diff --git a/tests/cloud_tests/testcases/modules/apt_configure_sources_keyserver.py b/tests/cloud_tests/testcases/modules/apt_configure_sources_keyserver.py
new file mode 100644
index 0000000..f476c47
--- /dev/null
+++ b/tests/cloud_tests/testcases/modules/apt_configure_sources_keyserver.py
@@ -0,0 +1,19 @@
+"""cloud-init Integration Test Verify Script"""
+from tests.cloud_tests.testcases import base
+
+
+class TestAptconfigureSourcesKeyserver(base.CloudTestCase):
+    """Test apt-configure module"""
+
+    def test_apt_key_list(self):
+        """Test specific key added"""
+        out = self.get_data_file('apt_key_list')
+        self.assertIn(
+            'E3A0 9FF8 3A2B 6158 6C9D  DB78 2257 4F06 10CB 188A', out)
+        self.assertIn('Launchpad PPA for Joshua Powers', out)
+
+    def test_source_list(self):
+        """Test source.list updated"""
+        out = self.get_data_file('sources.list')
+        self.assertIn(
+            'http://ppa.launchpad.net/powersj/debootstrap-1636583/ubuntu', out)
diff --git a/tests/cloud_tests/testcases/modules/apt_configure_sources_list.py b/tests/cloud_tests/testcases/modules/apt_configure_sources_list.py
new file mode 100644
index 0000000..aef4645
--- /dev/null
+++ b/tests/cloud_tests/testcases/modules/apt_configure_sources_list.py
@@ -0,0 +1,22 @@
+"""cloud-init Integration Test Verify Script"""
+from tests.cloud_tests.testcases import base
+
+
+class TestAptconfigureSourcesList(base.CloudTestCase):
+    """Test apt-configure module"""
+
+    def test_sources_list(self):
+        """Test sources.list includes sources"""
+        out = self.get_data_file('sources.list')
+        self.assertRegex(out, r'deb http:\/\/archive.ubuntu.com\/ubuntu '
+                         '[a-z].* main restricted')
+        self.assertRegex(out, r'deb-src http:\/\/archive.ubuntu.com\/ubuntu '
+                         '[a-z].* main restricted')
+        self.assertRegex(out, r'deb http:\/\/archive.ubuntu.com\/ubuntu '
+                         '[a-z].* universe restricted')
+        self.assertRegex(out, r'deb-src http:\/\/archive.ubuntu.com\/ubuntu '
+                         '[a-z].* universe restricted')
+        self.assertRegex(out, r'deb http:\/\/security.ubuntu.com\/ubuntu '
+                         '[a-z].*security multiverse')
+        self.assertRegex(out, r'deb-src http:\/\/security.ubuntu.com\/ubuntu '
+                         '[a-z].*security multiverse')
diff --git a/tests/cloud_tests/testcases/modules/apt_configure_sources_ppa.py b/tests/cloud_tests/testcases/modules/apt_configure_sources_ppa.py
new file mode 100644
index 0000000..1ce35bf
--- /dev/null
+++ b/tests/cloud_tests/testcases/modules/apt_configure_sources_ppa.py
@@ -0,0 +1,20 @@
+"""cloud-init Integration Test Verify Script"""
+from tests.cloud_tests.testcases import base
+
+
+class TestAptconfigureSourcesPPA(base.CloudTestCase):
+    """Test apt-configure module"""
+
+    def test_ppa(self):
+        """test specific ppa added"""
+        out = self.get_data_file('sources.list')
+        self.assertIn(
+            'deb http://ppa.launchpad.net/powersj/debootstrap-1636583/ubuntu',
+            out)
+
+    def test_ppa_key(self):
+        """test ppa key added"""
+        out = self.get_data_file('apt-key')
+        self.assertIn(
+            'E3A0 9FF8 3A2B 6158 6C9D  DB78 2257 4F06 10CB 188A', out)
+        self.assertIn('Launchpad PPA for Joshua Powers', out)
diff --git a/tests/cloud_tests/testcases/modules/apt_pipelining_disable.py b/tests/cloud_tests/testcases/modules/apt_pipelining_disable.py
new file mode 100644
index 0000000..255c398
--- /dev/null
+++ b/tests/cloud_tests/testcases/modules/apt_pipelining_disable.py
@@ -0,0 +1,11 @@
+"""cloud-init Integration Test Verify Script"""
+from tests.cloud_tests.testcases import base
+
+
+class TestAptPipeliningDisable(base.CloudTestCase):
+    """Test apt-pipelining module"""
+
+    def test_disable_pipelining(self):
+        """Test pipelining disabled"""
+        out = self.get_data_file('90cloud-init-pipelining')
+        self.assertIn('Acquire::http::Pipeline-Depth "0";', out)
diff --git a/tests/cloud_tests/testcases/modules/apt_pipelining_os.py b/tests/cloud_tests/testcases/modules/apt_pipelining_os.py
new file mode 100644
index 0000000..301f96c
--- /dev/null
+++ b/tests/cloud_tests/testcases/modules/apt_pipelining_os.py
@@ -0,0 +1,11 @@
+"""cloud-init Integration Test Verify Script"""
+from tests.cloud_tests.testcases import base
+
+
+class TestAptPipeliningOS(base.CloudTestCase):
+    """Test apt-pipelining module"""
+
+    def test_os_pipelining(self):
+        """Test pipelining set to os"""
+        out = self.get_data_file('90cloud-init-pipelining')
+        self.assertIn('Acquire::http::Pipeline-Depth "0";', out)
diff --git a/tests/cloud_tests/testcases/modules/bootcmd.py b/tests/cloud_tests/testcases/modules/bootcmd.py
new file mode 100644
index 0000000..ca6eaaa
--- /dev/null
+++ b/tests/cloud_tests/testcases/modules/bootcmd.py
@@ -0,0 +1,11 @@
+"""cloud-init Integration Test Verify Script"""
+from tests.cloud_tests.testcases import base
+
+
+class TestBootCmd(base.CloudTestCase):
+    """Test bootcmd module"""
+
+    def test_bootcmd_host(self):
+        """Test boot cmd worked"""
+        out = self.get_data_file('hosts')
+        self.assertIn('192.168.1.130 us.archive.ubuntu.com', out)
diff --git a/tests/cloud_tests/testcases/modules/byobu.py b/tests/cloud_tests/testcases/modules/byobu.py
new file mode 100644
index 0000000..59169a3
--- /dev/null
+++ b/tests/cloud_tests/testcases/modules/byobu.py
@@ -0,0 +1,21 @@
+"""cloud-init Integration Test Verify Script"""
+from tests.cloud_tests.testcases import base
+
+
+class TestByobu(base.CloudTestCase):
+    """Test Byobu module"""
+
+    def test_byobu_installed(self):
+        """Test byobu installed"""
+        out = self.get_data_file('byobu_installed')
+        self.assertIn('/usr/bin/byobu', out)
+
+    def test_byobu_profile_enabled(self):
+        """Test byobu profile.d file exists"""
+        out = self.get_data_file('byobu_profile_enabled')
+        self.assertIn('/etc/profile.d/Z97-byobu.sh', out)
+
+    def test_byobu_launch_exists(self):
+        """Test byobu-launch exists"""
+        out = self.get_data_file('byobu_launch_exists')
+        self.assertIn('/usr/bin/byobu-launch', out)
diff --git a/tests/cloud_tests/testcases/modules/ca_certs.py b/tests/cloud_tests/testcases/modules/ca_certs.py
new file mode 100644
index 0000000..f734240
--- /dev/null
+++ b/tests/cloud_tests/testcases/modules/ca_certs.py
@@ -0,0 +1,16 @@
+"""cloud-init Integration Test Verify Script"""
+from tests.cloud_tests.testcases import base
+
+
+class TestCaCerts(base.CloudTestCase):
+    """Test ca certs module"""
+
+    def test_cert_count(self):
+        """Test the count is proper"""
+        out = self.get_data_file('cert_count')
+        self.assertEqual(5, int(out))
+
+    def test_cert_installed(self):
+        """Test line from our cert exists"""
+        out = self.get_data_file('cert')
+        self.assertIn('a36c744454555024e7f82edc420fd2c8', out)
diff --git a/tests/cloud_tests/testcases/modules/debug_disable.py b/tests/cloud_tests/testcases/modules/debug_disable.py
new file mode 100644
index 0000000..ec3a46e
--- /dev/null
+++ b/tests/cloud_tests/testcases/modules/debug_disable.py
@@ -0,0 +1,12 @@
+"""cloud-init Integration Test Verify Script"""
+from tests.cloud_tests.testcases import base
+
+
+class TestDebugDisable(base.CloudTestCase):
+    """Disable debug messages"""
+
+    def test_debug_disable(self):
+        """Test verbose output missing from logs"""
+        out = self.get_data_file('cloud-init.log')
+        self.assertNotIn(
+            out, r'Skipping module named [a-z].* verbose printing disabled')
diff --git a/tests/cloud_tests/testcases/modules/debug_enable.py b/tests/cloud_tests/testcases/modules/debug_enable.py
new file mode 100644
index 0000000..b33804d
--- /dev/null
+++ b/tests/cloud_tests/testcases/modules/debug_enable.py
@@ -0,0 +1,11 @@
+"""cloud-init Integration Test Verify Script"""
+from tests.cloud_tests.testcases import base
+
+
+class TestDebugEnable(base.CloudTestCase):
+    """Test debug messages"""
+
+    def test_debug_enable(self):
+        """Test debug messages in cloud-init log"""
+        out = self.get_data_file('cloud-init.log')
+        self.assertIn('[DEBUG]', out)
diff --git a/tests/cloud_tests/testcases/modules/final_message.py b/tests/cloud_tests/testcases/modules/final_message.py
new file mode 100644
index 0000000..21e3536
--- /dev/null
+++ b/tests/cloud_tests/testcases/modules/final_message.py
@@ -0,0 +1,45 @@
+"""cloud-init Integration Test Verify Script"""
+from tests.cloud_tests.testcases import base
+
+
+class TestFinalMessage(base.CloudTestCase):
+    """
+    test cloud init module `cc_final_message`
+    """
+    subs_char = '$'
+
+    def get_final_message_config(self):
+        """
+        get config for final message
+        """
+        self.assertIn('final_message', self.cloud_config)
+        return self.cloud_config['final_message']
+
+    def get_final_message(self):
+        """
+        get final message from log
+        """
+        out = self.get_data_file('cloud-init-output.log')
+        lines = len(self.get_final_message_config().splitlines())
+        return '\n'.join(out.splitlines()[-1 * lines:])
+
+    def test_final_message_string(self):
+        """
+        ensure final handles regular strings
+        """
+        for actual, config in zip(
+                self.get_final_message().splitlines(),
+                self.get_final_message_config().splitlines()):
+            if self.subs_char not in config:
+                self.assertEqual(actual, config)
+
+    def test_final_message_subs(self):
+        """
+        test variable substitution in final message
+        """
+        # TODO: add verification of other substitutions
+        patterns = {'$datasource': self.get_datasource()}
+        for key, expected in patterns.items():
+            index = self.get_final_message_config().splitlines().index(key)
+            actual = self.get_final_message().splitlines()[index]
+            self.assertEqual(actual, expected)
diff --git a/tests/cloud_tests/testcases/modules/keys_to_console.py b/tests/cloud_tests/testcases/modules/keys_to_console.py
new file mode 100644
index 0000000..bee8810
--- /dev/null
+++ b/tests/cloud_tests/testcases/modules/keys_to_console.py
@@ -0,0 +1,18 @@
+"""cloud-init Integration Test Verify Script"""
+from tests.cloud_tests.testcases import base
+
+
+class TestKeysToConsole(base.CloudTestCase):
+    """Test proper keys are included and excluded to console"""
+
+    def test_excluded_keys(self):
+        """Test excluded keys missing"""
+        out = self.get_data_file('syslog')
+        self.assertNotIn('DSA', out)
+        self.assertNotIn('ECDSA', out)
+
+    def test_expected_keys(self):
+        """Test expected keys exist"""
+        out = self.get_data_file('syslog')
+        self.assertIn('ED25519', out)
+        self.assertIn('RSA', out)
diff --git a/tests/cloud_tests/testcases/modules/locale.py b/tests/cloud_tests/testcases/modules/locale.py
new file mode 100644
index 0000000..7482fa7
--- /dev/null
+++ b/tests/cloud_tests/testcases/modules/locale.py
@@ -0,0 +1,23 @@
+"""cloud-init Integration Test Verify Script"""
+from tests.cloud_tests.testcases import base
+
+
+class TestLocale(base.CloudTestCase):
+    """Test locale is set properly"""
+
+    def test_locale(self):
+        """Test locale is set properly"""
+        out = self.get_data_file('locale_default')
+        self.assertIn('LANG="en_GB.UTF-8"', out)
+
+    def test_locale_a(self):
+        """Test locale -a has both options"""
+        out = self.get_data_file('locale_a')
+        self.assertIn('en_GB.utf8', out)
+        self.assertIn('en_US.utf8', out)
+
+    def test_locale_gen(self):
+        """Test local.gen file has all entries."""
+        out = self.get_data_file('locale_gen')
+        self.assertIn('en_GB.UTF-8', out)
+        self.assertIn('en_US.UTF-8', out)
diff --git a/tests/cloud_tests/testcases/modules/lxd_bridge.py b/tests/cloud_tests/testcases/modules/lxd_bridge.py
new file mode 100644
index 0000000..5396742
--- /dev/null
+++ b/tests/cloud_tests/testcases/modules/lxd_bridge.py
@@ -0,0 +1,22 @@
+"""cloud-init Integration Test Verify Script"""
+from tests.cloud_tests.testcases import base
+
+
+class TestLxdBridge(base.CloudTestCase):
+    """Test LXD module"""
+
+    def test_lxd(self):
+        """Test lxd installed"""
+        out = self.get_data_file('lxd')
+        self.assertIn('/usr/bin/lxd', out)
+
+    def test_lxc(self):
+        """Test lxc installed"""
+        out = self.get_data_file('lxc')
+        self.assertIn('/usr/bin/lxc', out)
+
+    def test_bridge(self):
+        """Test bridge config"""
+        out = self.get_data_file('lxc-bridge')
+        self.assertIn('lxdbr0', out)
+        self.assertIn('10.100.100.1/24', out)
diff --git a/tests/cloud_tests/testcases/modules/lxd_dir.py b/tests/cloud_tests/testcases/modules/lxd_dir.py
new file mode 100644
index 0000000..d811de3
--- /dev/null
+++ b/tests/cloud_tests/testcases/modules/lxd_dir.py
@@ -0,0 +1,16 @@
+"""cloud-init Integration Test Verify Script"""
+from tests.cloud_tests.testcases import base
+
+
+class TestLxdDir(base.CloudTestCase):
+    """Test LXD module"""
+
+    def test_lxd(self):
+        """Test lxd installed"""
+        out = self.get_data_file('lxd')
+        self.assertIn('/usr/bin/lxd', out)
+
+    def test_lxc(self):
+        """Test lxc installed"""
+        out = self.get_data_file('lxc')
+        self.assertIn('/usr/bin/lxc', out)
diff --git a/tests/cloud_tests/testcases/modules/ntp.py b/tests/cloud_tests/testcases/modules/ntp.py
new file mode 100644
index 0000000..6450ad0
--- /dev/null
+++ b/tests/cloud_tests/testcases/modules/ntp.py
@@ -0,0 +1,24 @@
+"""cloud-init Integration Test Verify Script"""
+from tests.cloud_tests.testcases import base
+
+
+class TestNtp(base.CloudTestCase):
+    """Test ntp module"""
+
+    def test_ntp_installed(self):
+        """Test ntp installed"""
+        out = self.get_data_file('ntp_installed_empty')
+        self.assertEqual(1, int(out))
+
+    def test_ntp_dist_entries(self):
+        """Test dist config file has one entry"""
+        out = self.get_data_file('ntp_conf_dist_empty')
+        self.assertEqual(1, int(out))
+
+    def test_ntp_entires(self):
+        """Test config entries"""
+        out = self.get_data_file('ntp_conf_empty')
+        self.assertIn('pool 0.ubuntu.pool.ntp.org iburst', out)
+        self.assertIn('pool 1.ubuntu.pool.ntp.org iburst', out)
+        self.assertIn('pool 2.ubuntu.pool.ntp.org iburst', out)
+        self.assertIn('pool 3.ubuntu.pool.ntp.org iburst', out)
diff --git a/tests/cloud_tests/testcases/modules/ntp_pools.py b/tests/cloud_tests/testcases/modules/ntp_pools.py
new file mode 100644
index 0000000..9cc17d0
--- /dev/null
+++ b/tests/cloud_tests/testcases/modules/ntp_pools.py
@@ -0,0 +1,24 @@
+"""cloud-init Integration Test Verify Script"""
+from tests.cloud_tests.testcases import base
+
+
+class TestNtpPools(base.CloudTestCase):
+    """Test ntp module"""
+
+    def test_ntp_installed(self):
+        """Test ntp installed"""
+        out = self.get_data_file('ntp_installed_pools')
+        self.assertEqual(1, int(out))
+
+    def test_ntp_dist_entries(self):
+        """Test dist config file has one entry"""
+        out = self.get_data_file('ntp_conf_dist_pools')
+        self.assertEqual(1, int(out))
+
+    def test_ntp_entires(self):
+        """Test config entries"""
+        out = self.get_data_file('ntp_conf_pools')
+        self.assertIn('pool 0.pool.ntp.org iburst', out)
+        self.assertIn('pool 1.pool.ntp.org iburst', out)
+        self.assertIn('pool 2.pool.ntp.org iburst', out)
+        self.assertIn('pool 3.pool.ntp.org iburst', out)
diff --git a/tests/cloud_tests/testcases/modules/ntp_servers.py b/tests/cloud_tests/testcases/modules/ntp_servers.py
new file mode 100644
index 0000000..d47d42e
--- /dev/null
+++ b/tests/cloud_tests/testcases/modules/ntp_servers.py
@@ -0,0 +1,21 @@
+"""cloud-init Integration Test Verify Script"""
+from tests.cloud_tests.testcases import base
+
+
+class TestNtpServers(base.CloudTestCase):
+    """Test ntp module"""
+
+    def test_ntp_installed(self):
+        """Test ntp installed"""
+        out = self.get_data_file('ntp_installed_servers')
+        self.assertEqual(1, int(out))
+
+    def test_ntp_dist_entries(self):
+        """Test dist config file has one entry"""
+        out = self.get_data_file('ntp_conf_dist_servers')
+        self.assertEqual(1, int(out))
+
+    def test_ntp_entires(self):
+        """Test config entries"""
+        out = self.get_data_file('ntp_conf_servers')
+        self.assertIn('server pool.ntp.org iburst', out)
diff --git a/tests/cloud_tests/testcases/modules/package_update_upgrade_install.py b/tests/cloud_tests/testcases/modules/package_update_upgrade_install.py
new file mode 100644
index 0000000..745d768
--- /dev/null
+++ b/tests/cloud_tests/testcases/modules/package_update_upgrade_install.py
@@ -0,0 +1,34 @@
+"""cloud-init Integration Test Verify Script"""
+from tests.cloud_tests.testcases import base
+
+
+class TestPackageInstallUpdateUpgrade(base.CloudTestCase):
+    """Test package install update upgrade module"""
+
+    def test_installed_htop(self):
+        """Test htop got installed"""
+        out = self.get_data_file('dpkg_htop')
+        self.assertEqual(1, int(out))
+
+    def test_installed_tree(self):
+        """Test tree got installed"""
+        out = self.get_data_file('dpkg_tree')
+        self.assertEqual(1, int(out))
+
+    def test_apt_history(self):
+        """Test apt history for update command"""
+        out = self.get_data_file('apt_history_cmdline')
+        self.assertIn(
+            'Commandline: /usr/bin/apt-get --option=Dpkg::Options'
+            '::=--force-confold --option=Dpkg::options::=--force-unsafe-io '
+            '--assume-yes --quiet install htop tree', out)
+
+    def test_cloud_init_output(self):
+        """Test cloud-init-output for install & upgrade stuff"""
+        out = self.get_data_file('cloud-init-output.log')
+        self.assertIn('Setting up tree (', out)
+        self.assertIn('Setting up htop (', out)
+        self.assertIn('Reading package lists...', out)
+        self.assertIn('Building dependency tree...', out)
+        self.assertIn('Reading state information...', out)
+        self.assertIn('Calculating upgrade...', out)
diff --git a/tests/cloud_tests/testcases/modules/runcmd.py b/tests/cloud_tests/testcases/modules/runcmd.py
new file mode 100644
index 0000000..cd2c4b0
--- /dev/null
+++ b/tests/cloud_tests/testcases/modules/runcmd.py
@@ -0,0 +1,11 @@
+"""cloud-init Integration Test Verify Script"""
+from tests.cloud_tests.testcases import base
+
+
+class TestRunCmd(base.CloudTestCase):
+    """Test runcmd module"""
+
+    def test_run_cmd(self):
+        """Test run command worked"""
+        out = self.get_data_file('run_cmd')
+        self.assertIn('cloud-init run cmd test', out)
diff --git a/tests/cloud_tests/testcases/modules/salt_minion.py b/tests/cloud_tests/testcases/modules/salt_minion.py
new file mode 100644
index 0000000..0f6c768
--- /dev/null
+++ b/tests/cloud_tests/testcases/modules/salt_minion.py
@@ -0,0 +1,25 @@
+"""cloud-init Integration Test Verify Script"""
+from tests.cloud_tests.testcases import base
+
+
+class Test(base.CloudTestCase):
+    """Test salt minion module"""
+
+    def test_minon_master(self):
+        """Test master value in config"""
+        out = self.get_data_file('minion')
+        self.assertIn('master: salt.mydomain.com', out)
+
+    def test_minion_pem(self):
+        """Test private key"""
+        out = self.get_data_file('minion.pem')
+        self.assertIn('------BEGIN PRIVATE KEY------', out)
+        self.assertIn('<key data>', out)
+        self.assertIn('------END PRIVATE KEY-------', out)
+
+    def test_minion_pub(self):
+        """Test public key"""
+        out = self.get_data_file('minion.pub')
+        self.assertIn('------BEGIN PUBLIC KEY-------', out)
+        self.assertIn('<key data>', out)
+        self.assertIn('------END PUBLIC KEY-------', out)
diff --git a/tests/cloud_tests/testcases/modules/seed_random_data.py b/tests/cloud_tests/testcases/modules/seed_random_data.py
new file mode 100644
index 0000000..e43e882
--- /dev/null
+++ b/tests/cloud_tests/testcases/modules/seed_random_data.py
@@ -0,0 +1,11 @@
+"""cloud-init Integration Test Verify Script"""
+from tests.cloud_tests.testcases import base
+
+
+class TestSeedRandom(base.CloudTestCase):
+    """Test seed random module"""
+
+    def test_random_seed_data(self):
+        """Test random data passed in exists"""
+        out = self.get_data_file('seed_data')
+        self.assertIn('MYUb34023nD:LFDK10913jk;dfnk:Df', out)
diff --git a/tests/cloud_tests/testcases/modules/set_hostname.py b/tests/cloud_tests/testcases/modules/set_hostname.py
new file mode 100644
index 0000000..6678075
--- /dev/null
+++ b/tests/cloud_tests/testcases/modules/set_hostname.py
@@ -0,0 +1,11 @@
+"""cloud-init Integration Test Verify Script"""
+from tests.cloud_tests.testcases import base
+
+
+class TestHostname(base.CloudTestCase):
+    """Test hostname module"""
+
+    def test_hostname(self):
+        """Test hostname command shows correct output"""
+        out = self.get_data_file('hostname')
+        self.assertIn('myhostname', out)
diff --git a/tests/cloud_tests/testcases/modules/set_hostname_fqdn.py b/tests/cloud_tests/testcases/modules/set_hostname_fqdn.py
new file mode 100644
index 0000000..18c7877
--- /dev/null
+++ b/tests/cloud_tests/testcases/modules/set_hostname_fqdn.py
@@ -0,0 +1,22 @@
+"""cloud-init Integration Test Verify Script"""
+from tests.cloud_tests.testcases import base
+
+
+class TestHostnameFqdn(base.CloudTestCase):
+    """Test Hostname module"""
+
+    def test_hostname(self):
+        """Test hostname output"""
+        out = self.get_data_file('hostname')
+        self.assertIn('myhostname', out)
+
+    def test_hostname_fqdn(self):
+        """Test hostname fqdn output"""
+        out = self.get_data_file('fqdn')
+        self.assertIn('host.myorg.com', out)
+
+    def test_hosts(self):
+        """Test /etc/hosts file"""
+        out = self.get_data_file('hosts')
+        self.assertIn('127.0.1.1 host.myorg.com myhostname', out)
+        self.assertIn('127.0.0.1 localhost', out)
diff --git a/tests/cloud_tests/testcases/modules/set_password.py b/tests/cloud_tests/testcases/modules/set_password.py
new file mode 100644
index 0000000..0d0b29a
--- /dev/null
+++ b/tests/cloud_tests/testcases/modules/set_password.py
@@ -0,0 +1,18 @@
+"""cloud-init Integration Test Verify Script"""
+from tests.cloud_tests.testcases import base
+
+
+class TestPassword(base.CloudTestCase):
+    """Test password module"""
+
+    # TODO add test to make sure password is actually "password"
+
+    def test_shadow(self):
+        """Test ubuntu user in shadow"""
+        out = self.get_data_file('shadow')
+        self.assertIn('ubuntu:', out)
+
+    def test_sshd_config(self):
+        """Test sshd config allows passwords"""
+        out = self.get_data_file('sshd_config')
+        self.assertIn('PasswordAuthentication yes', out)
diff --git a/tests/cloud_tests/testcases/modules/set_password_expire.py b/tests/cloud_tests/testcases/modules/set_password_expire.py
new file mode 100644
index 0000000..c3ace07
--- /dev/null
+++ b/tests/cloud_tests/testcases/modules/set_password_expire.py
@@ -0,0 +1,19 @@
+"""cloud-init Integration Test Verify Script"""
+from tests.cloud_tests.testcases import base
+
+
+class TestPasswordExpire(base.CloudTestCase):
+    """Test password module"""
+
+    def test_shadow(self):
+        """Test user frozen in shadow"""
+        out = self.get_data_file('shadow')
+        self.assertIn('harry:!:', out)
+        self.assertIn('dick:!:', out)
+        self.assertIn('tom:!:', out)
+        self.assertIn('harry:!:', out)
+
+    def test_sshd_config(self):
+        """Test sshd config allows passwords"""
+        out = self.get_data_file('sshd_config')
+        self.assertIn('PasswordAuthentication no', out)
diff --git a/tests/cloud_tests/testcases/modules/set_password_list.py b/tests/cloud_tests/testcases/modules/set_password_list.py
new file mode 100644
index 0000000..db56791
--- /dev/null
+++ b/tests/cloud_tests/testcases/modules/set_password_list.py
@@ -0,0 +1,21 @@
+"""cloud-init Integration Test Verify Script"""
+from tests.cloud_tests.testcases import base
+
+
+class TestPasswordList(base.CloudTestCase):
+    """Test password module"""
+
+    # TODO: Verify dick and harry passwords are random
+    # TODO: Verify tom's password was changed
+
+    def test_shadow(self):
+        """Test every tom, dick, and harry user in shadow"""
+        out = self.get_data_file('shadow')
+        self.assertIn('tom:', out)
+        self.assertIn('dick:', out)
+        self.assertIn('harry:', out)
+
+    def test_sshd_config(self):
+        """Test sshd config allows passwords"""
+        out = self.get_data_file('sshd_config')
+        self.assertIn('PasswordAuthentication yes', out)
diff --git a/tests/cloud_tests/testcases/modules/snappy.py b/tests/cloud_tests/testcases/modules/snappy.py
new file mode 100644
index 0000000..3848f71
--- /dev/null
+++ b/tests/cloud_tests/testcases/modules/snappy.py
@@ -0,0 +1,14 @@
+"""cloud-init Integration Test Verify Script"""
+from tests.cloud_tests.testcases import base
+
+
+class TestSnappy(base.CloudTestCase):
+    """Test snappy module"""
+
+    def test_snappy_version(self):
+        """Test snappy version output"""
+        out = self.get_data_file('snap_version')
+        self.assertIn('snap ', out)
+        self.assertIn('snapd ', out)
+        self.assertIn('series ', out)
+        self.assertIn('ubuntu ', out)
diff --git a/tests/cloud_tests/testcases/modules/ssh_auth_key_fingerprints_disable.py b/tests/cloud_tests/testcases/modules/ssh_auth_key_fingerprints_disable.py
new file mode 100644
index 0000000..40678ac
--- /dev/null
+++ b/tests/cloud_tests/testcases/modules/ssh_auth_key_fingerprints_disable.py
@@ -0,0 +1,20 @@
+"""cloud-init Integration Test Verify Script"""
+from tests.cloud_tests.testcases import base
+
+
+class TestSshKeyFingerprintsDisable(base.CloudTestCase):
+    """Test ssh key fingerprints module"""
+
+    def test_cloud_init_log(self):
+        """Verify disabled"""
+        out = self.get_data_file('cloud-init.log')
+        self.assertIn('Skipping module named ssh-authkey-fingerprints, '
+                      'logging of ssh fingerprints disabled', out)
+
+    def test_syslog(self):
+        """Verify output of syslog"""
+        out = self.get_data_file('syslog')
+        self.assertNotRegexpMatches(out, r'256 SHA256:.*(ECDSA)')
+        self.assertNotRegexpMatches(out, r'256 SHA256:.*(ED25519)')
+        self.assertNotRegexpMatches(out, r'1024 SHA256:.*(DSA)')
+        self.assertNotRegexpMatches(out, r'2048 SHA256:.*(RSA)')
diff --git a/tests/cloud_tests/testcases/modules/ssh_auth_key_fingerprints_enable.py b/tests/cloud_tests/testcases/modules/ssh_auth_key_fingerprints_enable.py
new file mode 100644
index 0000000..1c9f0fc
--- /dev/null
+++ b/tests/cloud_tests/testcases/modules/ssh_auth_key_fingerprints_enable.py
@@ -0,0 +1,14 @@
+"""cloud-init Integration Test Verify Script"""
+from tests.cloud_tests.testcases import base
+
+
+class TestSshKeyFingerprintsEnable(base.CloudTestCase):
+    """Test ssh key fingerprints module"""
+
+    def test_syslog(self):
+        """Verify output of syslog"""
+        out = self.get_data_file('syslog')
+        self.assertRegexpMatches(out, r'256 SHA256:.*(ECDSA)')
+        self.assertRegexpMatches(out, r'256 SHA256:.*(ED25519)')
+        self.assertNotRegexpMatches(out, r'1024 SHA256:.*(DSA)')
+        self.assertNotRegexpMatches(out, r'2048 SHA256:.*(RSA)')
diff --git a/tests/cloud_tests/testcases/modules/ssh_import_id.py b/tests/cloud_tests/testcases/modules/ssh_import_id.py
new file mode 100644
index 0000000..44b25c0
--- /dev/null
+++ b/tests/cloud_tests/testcases/modules/ssh_import_id.py
@@ -0,0 +1,22 @@
+"""cloud-init Integration Test Verify Script"""
+from tests.cloud_tests.testcases import base
+
+
+class TestSshImportId(base.CloudTestCase):
+    """Test ssh import id module"""
+
+    def test_authorized_keys(self):
+        """Test that ssh keys were imported"""
+        out = self.get_data_file('auth_keys_ubuntu')
+
+        # Rather than checking the key fingerprints, you could just check
+        # the ending comment for where it got imported from in case these
+        # change in the future :\
+        self.assertIn('8sXGTYYw3iQSkOvDUUlIsqdaO+w== powersj@github/'
+                      '18564351 # ssh-import-id gh:powersj', out)
+        self.assertIn('Hj29SCmXp5Kt5/82cD/VN3NtHw== smoser@brickies-'
+                      'canonical # ssh-import-id lp:smoser', out)
+        self.assertIn('7cUDQSXbabilgnzTjHo9mjd/kZ7cLOHP smoser@bart-'
+                      'canonical # ssh-import-id lp:smoser', out)
+        self.assertIn('aX0VHGXvHAQlPl4n7+FzAE1UmWFYEGrsSoNvLv3 smose'
+                      'r@kaypeah # ssh-import-id lp:smoser', out)
diff --git a/tests/cloud_tests/testcases/modules/ssh_keys_generate.py b/tests/cloud_tests/testcases/modules/ssh_keys_generate.py
new file mode 100644
index 0000000..3bd3089
--- /dev/null
+++ b/tests/cloud_tests/testcases/modules/ssh_keys_generate.py
@@ -0,0 +1,59 @@
+"""cloud-init Integration Test Verify Script"""
+from tests.cloud_tests.testcases import base
+
+
+class TestSshKeysGenerate(base.CloudTestCase):
+    """Test ssh keys module"""
+
+    # TODO: Check cloud-init-output for the correct keys being generated
+
+    def test_ubuntu_authorized_keys(self):
+        """Test passed in key is not in list for ubuntu"""
+        out = self.get_data_file('auth_keys_ubuntu')
+        self.assertEqual('', out)
+
+    def test_root_authorized_keys(self):
+        """Test passed in key is in authorized list for root"""
+        out = self.get_data_file('auth_keys_root')
+        self.assertIn('lzrkPqONphoZx0LDV86w7RUz1ksDzAdcm0tvmNRFMN1a0frDs50'
+                      '6oA3aWK0oDk4Nmvk8sXGTYYw3iQSkOvDUUlIsqdaO+w==', out)
+
+    def test_dsa_public(self):
+        """Test dsa public key not generated"""
+        out = self.get_data_file('dsa_public')
+        self.assertEqual('', out)
+
+    def test_dsa_private(self):
+        """Test dsa private key not generated"""
+        out = self.get_data_file('dsa_private')
+        self.assertEqual('', out)
+
+    def test_rsa_public(self):
+        """Test rsa public key not generated"""
+        out = self.get_data_file('rsa_public')
+        self.assertEqual('', out)
+
+    def test_rsa_private(self):
+        """Test rsa public key not generated"""
+        out = self.get_data_file('rsa_private')
+        self.assertEqual('', out)
+
+    def test_ecdsa_public(self):
+        """Test ecdsa public key generated"""
+        out = self.get_data_file('ecdsa_public')
+        self.assertIsNotNone(out)
+
+    def test_ecdsa_private(self):
+        """Test ecdsa public key generated"""
+        out = self.get_data_file('ecdsa_private')
+        self.assertIsNotNone(out)
+
+    def test_ed25519_public(self):
+        """Test ed25519 public key generated"""
+        out = self.get_data_file('ed25519_public')
+        self.assertIsNotNone(out)
+
+    def test_ed25519_private(self):
+        """Test ed25519 public key generated"""
+        out = self.get_data_file('ed25519_private')
+        self.assertIsNotNone(out)
diff --git a/tests/cloud_tests/testcases/modules/ssh_keys_provided.py b/tests/cloud_tests/testcases/modules/ssh_keys_provided.py
new file mode 100644
index 0000000..a6d8029
--- /dev/null
+++ b/tests/cloud_tests/testcases/modules/ssh_keys_provided.py
@@ -0,0 +1,65 @@
+"""cloud-init Integration Test Verify Script"""
+from tests.cloud_tests.testcases import base
+
+
+class TestSshKeysProvided(base.CloudTestCase):
+    """Test ssh keys module"""
+
+    def test_ubuntu_authorized_keys(self):
+        """Test passed in key is not in list for ubuntu"""
+        out = self.get_data_file('auth_keys_ubuntu')
+        self.assertEqual('', out)
+
+    def test_root_authorized_keys(self):
+        """Test passed in key is in authorized list for root"""
+        out = self.get_data_file('auth_keys_root')
+        self.assertIn('lzrkPqONphoZx0LDV86w7RUz1ksDzAdcm0tvmNRFMN1a0frDs50'
+                      '6oA3aWK0oDk4Nmvk8sXGTYYw3iQSkOvDUUlIsqdaO+w==', out)
+
+    def test_dsa_public(self):
+        """Test dsa public key passed in"""
+        out = self.get_data_file('dsa_public')
+        self.assertIn('AAAAB3NzaC1kc3MAAACBAPkWy1zbchVIN7qTgM0/yyY8q4RZS8c'
+                      'NM4ZpeuE5UB/Nnr6OSU/nmbO8LuM', out)
+
+    def test_dsa_private(self):
+        """Test dsa private key passed in"""
+        out = self.get_data_file('dsa_private')
+        self.assertIn('MIIBuwIBAAKBgQD5Fstc23IVSDe6k4DNP8smPKuEWUvHDTOGaXr'
+                      'hOVAfzZ6+jklP', out)
+
+    def test_rsa_public(self):
+        """Test rsa public key passed in"""
+        out = self.get_data_file('rsa_public')
+        self.assertIn('AAAAB3NzaC1yc2EAAAADAQABAAABAQC0/Ho+o3eJISydO2JvIgT'
+                      'LnZOtrxPl+fSvJfKDjoOLY0HB2eOjy2s2/2N6d9X9SGZ4', out)
+
+    def test_rsa_private(self):
+        """Test rsa public key passed in"""
+        out = self.get_data_file('rsa_private')
+        self.assertIn('4DOkqNiUGl80Zp1RgZNohHUXlJMtAbrIlAVEk+mTmg7vjfyp2un'
+                      'RQvLZpMRdywBm', out)
+
+    def test_ecdsa_public(self):
+        """Test ecdsa public key passed in"""
+        out = self.get_data_file('ecdsa_public')
+        self.assertIn('AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAAB'
+                      'BBFsS5Tvky/IC/dXhE/afxxU', out)
+
+    def test_ecdsa_private(self):
+        """Test ecdsa public key passed in"""
+        out = self.get_data_file('ecdsa_private')
+        self.assertIn('AwEHoUQDQgAEWxLlO+TL8gL91eET9p/HFQbqR1A691AkJgZk3jY'
+                      '5mpZqxgX4vcgb', out)
+
+    def test_ed25519_public(self):
+        """Test ed25519 public key passed in"""
+        out = self.get_data_file('ed25519_public')
+        self.assertIn('AAAAC3NzaC1lZDI1NTE5AAAAINudAZSu4vjZpVWzId5pXmZg1M6'
+                      'G15dqjQ2XkNVOEnb5', out)
+
+    def test_ed25519_private(self):
+        """Test ed25519 public key passed in"""
+        out = self.get_data_file('ed25519_private')
+        self.assertIn('XAAAAAtzc2gtZWQyNTUxOQAAACDbnQGUruL42aVVsyHeaV5mYNT'
+                      'OhteXao0Nl5DVThJ2+Q', out)
diff --git a/tests/cloud_tests/testcases/modules/timezone.py b/tests/cloud_tests/testcases/modules/timezone.py
new file mode 100644
index 0000000..a102918
--- /dev/null
+++ b/tests/cloud_tests/testcases/modules/timezone.py
@@ -0,0 +1,11 @@
+"""cloud-init Integration Test Verify Script"""
+from tests.cloud_tests.testcases import base
+
+
+class TestTimezone(base.CloudTestCase):
+    """Test timezone module"""
+
+    def test_timezone(self):
+        """Test date prints correct timezone"""
+        out = self.get_data_file('timezone')
+        self.assertIn('HST', out)
diff --git a/tests/cloud_tests/testcases/modules/user_groups.py b/tests/cloud_tests/testcases/modules/user_groups.py
new file mode 100644
index 0000000..704f699
--- /dev/null
+++ b/tests/cloud_tests/testcases/modules/user_groups.py
@@ -0,0 +1,39 @@
+"""cloud-init Integration Test Verify Script"""
+from tests.cloud_tests.testcases import base
+
+
+class TestUserGroups(base.CloudTestCase):
+    """Example cloud-config test"""
+
+    def test_group_ubuntu(self):
+        """Test ubuntu group exists"""
+        out = self.get_data_file('group_ubuntu')
+        self.assertRegex(out, r'ubuntu:x:[0-9]{4}:')
+
+    def test_group_cloud_users(self):
+        """Test cloud users group exists"""
+        out = self.get_data_file('group_cloud_users')
+        self.assertRegex(out, r'cloud-users:x:[0-9]{4}:barfoo')
+
+    def test_user_ubuntu(self):
+        """Test ubuntu user exists"""
+        out = self.get_data_file('user_ubuntu')
+        self.assertRegex(
+            out, r'ubuntu:x:[0-9]{4}:[0-9]{4}:Ubuntu:/home/ubuntu:/bin/bash')
+
+    def test_user_foobar(self):
+        """Test foobar user exists"""
+        out = self.get_data_file('user_foobar')
+        self.assertRegex(
+            out, r'foobar:x:[0-9]{4}:[0-9]{4}:Foo B. Bar:/home/foobar:')
+
+    def test_user_barfoo(self):
+        """Test barfoo user exists"""
+        out = self.get_data_file('user_barfoo')
+        self.assertRegex(
+            out, r'barfoo:x:[0-9]{4}:[0-9]{4}:Bar B. Foo:/home/barfoo:')
+
+    def test_user_cloudy(self):
+        """Test cloudy user exists"""
+        out = self.get_data_file('user_cloudy')
+        self.assertRegex(out, r'cloudy:x:[0-9]{3,4}:')
diff --git a/tests/cloud_tests/testcases/modules/write_files.py b/tests/cloud_tests/testcases/modules/write_files.py
new file mode 100644
index 0000000..97b6d2c
--- /dev/null
+++ b/tests/cloud_tests/testcases/modules/write_files.py
@@ -0,0 +1,26 @@
+"""cloud-init Integration Test Verify Script"""
+from tests.cloud_tests.testcases import base
+
+
+class TestWriteFiles(base.CloudTestCase):
+    """Example cloud-config test"""
+
+    def test_b64(self):
+        """Test b64 encoded file reads as ascii"""
+        out = self.get_data_file('file_b64')
+        self.assertIn('ASCII text', out)
+
+    def test_binary(self):
+        """Test binary file reads as executable"""
+        out = self.get_data_file('file_binary')
+        self.assertIn('ELF 64-bit LSB executable, x86-64, version 1', out)
+
+    def test_gzip(self):
+        """Test gzip file shows up as a shell script"""
+        out = self.get_data_file('file_gzip')
+        self.assertIn('POSIX shell script, ASCII text executable', out)
+
+    def test_text(self):
+        """Test text shows up as ASCII text"""
+        out = self.get_data_file('file_text')
+        self.assertIn('ASCII text', out)
diff --git a/tests/cloud_tests/util.py b/tests/cloud_tests/util.py
new file mode 100644
index 0000000..68ec30e
--- /dev/null
+++ b/tests/cloud_tests/util.py
@@ -0,0 +1,159 @@
+import glob
+import os
+import random
+import string
+import tempfile
+import yaml
+
+from cloudinit.distros import OSFAMILIES
+from cloudinit import util as c_util
+from tests.cloud_tests import LOG
+
+
+def list_test_data(data_dir):
+    """
+    find all tests with test data available in data_dir
+    data_dir should contain <platforms>/<os_name>/<testnames>/<data>
+    return_value: {<platform>: {<os_name>: [<testname>]}}
+    """
+    if not os.path.isdir(data_dir):
+        raise ValueError("bad data dir")
+
+    res = {}
+    for platform in os.listdir(data_dir):
+        res[platform] = {}
+        for os_name in os.listdir(os.path.join(data_dir, platform)):
+            res[platform][os_name] = [
+                os.path.sep.join(f.split(os.path.sep)[-2:]) for f in
+                glob.glob(os.sep.join((data_dir, platform, os_name, '*/*')))]
+
+    LOG.debug('found test data: %s\n', res)
+    return res
+
+
+def gen_instance_name(prefix='cloud-test', image_desc=None, use_desc=None,
+                      max_len=63, delim='-', max_tries=16, used_list=None,
+                      valid=string.ascii_lowercase + string.digits):
+    """
+    generate an unique name for a test instance
+    prefix: name prefix, defaults to cloud-test, default should be left
+    image_desc: short string with image desc, will be truncated to 16 chars
+    use_desc: short string with usage desc, will be truncated to 30 chars
+    max_len: maximum name length, defaults to 64 chars
+    delim: delimiter to use between tokens
+    max_tries: maximum tries to find a unique name before giving up
+    used_list: already used names, or none to not check
+    valid: string of valid characters for name
+    return_value: valid, unused name, may raise StopIteration
+    """
+    unknown = 'unknown'
+
+    def join(*args):
+        """
+        join args with delim
+        """
+        return delim.join(args)
+
+    def fill(*args):
+        """
+        join name elems and fill rest with random data
+        """
+        name = join(*args)
+        num = max_len - len(name) - len(delim)
+        return join(name, ''.join(random.choice(valid) for _ in range(num)))
+
+    def clean(elem, max_len):
+        """
+        filter bad characters out of elem and trim to length
+        """
+        elem = elem[: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
+                (fill(prefix, clean(image_desc, 16), clean(use_desc, 30))
+                 for _ in range(max_tries))
+                if not used_list or name not in used_list)
+
+
+def sorted_unique(iterable, key=None, reverse=False):
+    """
+    return_value: a sorted list of unique items in iterable
+    """
+    return sorted(set(iterable), key=key, reverse=reverse)
+
+
+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)
+
+
+def current_verbosity():
+    """
+    get verbosity currently in effect from log level
+    return_value: verbosity, 0-2, 2 = verbose, 0 = quiet
+    """
+    return max(min(3 - int(LOG.level / 10), 2), 0)
+
+
+def is_writable_dir(path):
+    """
+    make sure dir is writable
+    """
+    try:
+        c_util.ensure_dir(path)
+        os.remove(tempfile.mkstemp(dir=os.path.abspath(path))[1])
+    except (IOError, OSError):
+        return False
+    return True
+
+
+def is_clean_writable_dir(path):
+    """
+    make sure dir is empty and writable, creating it if it does not exist
+    return_value: True/False if successful
+    """
+    path = os.path.abspath(path)
+    if not (is_writable_dir(path) and len(os.listdir(path)) == 0):
+        return False
+    return True
+
+
+def configure_yaml():
+    yaml.add_representer(str, (lambda dumper, data: dumper.represent_scalar(
+        'tag:yaml.org,2002:str', data, style='|' if '\n' in data else '')))
+
+
+def yaml_format(data):
+    """
+    format data as yaml
+    """
+    configure_yaml()
+    return yaml.dump(data, indent=2, default_flow_style=False)
+
+
+def yaml_dump(data, path):
+    """
+    dump data to path in yaml format
+    """
+    write_file(os.path.abspath(path), yaml_format(data), omode='w')
+
+
+def merge_results(data, path):
+    """
+    handle merging results from collect phase and verify phase
+    """
+    current = {}
+    if os.path.exists(path):
+        with open(path, 'r') as fp:
+            current = c_util.load_yaml(fp.read())
+    current.update(data)
+    yaml_dump(current, path)
+
+
+def write_file(*args, **kwargs):
+    """
+    write a file using cloudinit.util.write_file
+    """
+    c_util.write_file(*args, **kwargs)
diff --git a/tests/cloud_tests/verify.py b/tests/cloud_tests/verify.py
new file mode 100644
index 0000000..8378d1e
--- /dev/null
+++ b/tests/cloud_tests/verify.py
@@ -0,0 +1,89 @@
+from tests.cloud_tests import (config, LOG, util, testcases)
+
+import os
+import unittest
+
+
+def verify_data(base_dir, tests):
+    """
+    verify test data is correct,
+    base_dir: base directory for data
+    test_config: dict of all test config, from util.load_test_config()
+    tests: list of test names
+    return_value: {<test_name>: {passed: True/False, failures: []}}
+    """
+    runner = unittest.TextTestRunner(verbosity=util.current_verbosity())
+    res = {}
+    for test_name in tests:
+        LOG.debug('verifying test data for %s', test_name)
+
+        # get cloudconfig for test
+        test_conf = config.load_test_config(test_name)
+        test_module = config.name_to_module(test_name)
+        cloud_conf = test_conf['cloud_config']
+
+        # load script outputs
+        data = {}
+        test_dir = os.path.join(base_dir, test_name)
+        for script_name in os.listdir(test_dir):
+            with open(os.path.join(test_dir, script_name), 'r') as fp:
+                data[script_name] = fp.read()
+
+        # get test suite and launch tests
+        suite = testcases.get_suite(test_module, data, cloud_conf)
+        suite_results = runner.run(suite)
+        res[test_name] = {
+            'passed': suite_results.wasSuccessful(),
+            'failures': [{'module': type(test_class).__base__.__module__,
+                          'class': type(test_class).__base__.__name__,
+                          'function': str(test_class).split()[0],
+                          'error': trace.splitlines()[-1],
+                          'traceback': trace, }
+                         for test_class, trace in suite_results.failures]
+        }
+
+        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'])
+
+    return res
+
+
+def verify(args):
+    """
+    verify test data
+    return_value: 0 for success, or number of failed tests
+    """
+    failed = 0
+    res = {}
+
+    # find test data
+    tests = util.list_test_data(args.data_dir)
+
+    for platform in tests.keys():
+        res[platform] = {}
+        for os_name in tests[platform].keys():
+            test_name = "platform='{}', os='{}'".format(platform, os_name)
+            LOG.info('test: %s verifying test data', test_name)
+
+            # run test
+            res[platform][os_name] = verify_data(
+                os.sep.join((args.data_dir, platform, os_name)),
+                tests[platform][os_name])
+
+            # handle results
+            fail_list = [k for k, v in res[platform][os_name].items()
+                         if not v.get('passed')]
+            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))
+            failed += len(fail_list)
+
+    # dump results
+    LOG.debug('verify results: %s', res)
+    if args.result:
+        util.merge_results({'verify': res}, args.result)
+
+    return failed
diff --git a/tox.ini b/tox.ini
index 08318a9..fb1ab9e 100644
--- a/tox.ini
+++ b/tox.ini
@@ -3,7 +3,7 @@ envlist = py27, py3, flake8, xenial
 recreate = True
 
 [testenv]
-commands = python -m nose {posargs:tests}
+commands = python -m nose {posargs:tests/unittests}
 deps = -r{toxinidir}/test-requirements.txt
        -r{toxinidir}/requirements.txt
 setenv =
@@ -24,7 +24,7 @@ commands = {envpython} -m nose \
         --cover-branches --cover-package=cloudinit --cover-inclusive}
 
 [testenv:py26]
-commands = nosetests {posargs:tests}
+commands = nosetests {posargs:tests/unittests}
 setenv =
     LC_ALL = C
 

Follow ups