← Back to team overview

cloud-init-dev team mailing list archive

[Merge] ~powersj/cloud-init:cii-kvm into cloud-init:master

 

Joshua Powers has proposed merging ~powersj/cloud-init:cii-kvm into cloud-init:master.

Commit message:
test: Enable the KVM platform on integration tests
    
The KVM platform includes:
    
  * Ubuntu images from daily stream
  * Image setup via mount-image-callback
  * Generation and injection of SSH key pair (RSA 4096)
  * Launching via QEMU CLI and execution via SSH


Requested reviews:
  cloud-init commiters (cloud-init-dev)

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

Would like an initial review, please!

Example command:
$ python3 -m tests.cloud_tests run --verbose --platform kvm --os-name xenial -t modules/locale

Note, not all existing tests work with the kvm. This is because lxd runs with the root user, whereas kvm runs as a normal 'ubuntu' user. Certain assumptions were made with lxd that do not apply to kvm. As such updated test cases will come in a later merge.



-- 
Your team cloud-init commiters is requested to review the proposed merge of ~powersj/cloud-init:cii-kvm into cloud-init:master.
diff --git a/tests/cloud_tests/__main__.py b/tests/cloud_tests/__main__.py
index 260ddb3..7ee29ca 100644
--- a/tests/cloud_tests/__main__.py
+++ b/tests/cloud_tests/__main__.py
@@ -4,6 +4,7 @@
 
 import argparse
 import logging
+import os
 import sys
 
 from tests.cloud_tests import args, bddeb, collect, manage, run_funcs, verify
@@ -50,7 +51,7 @@ def main():
             return -1
 
     # run handler
-    LOG.debug('running with args: %s\n', parsed)
+    LOG.debug('running with args: %s', parsed)
     return {
         'bddeb': bddeb.bddeb,
         'collect': collect.collect,
@@ -63,6 +64,8 @@ def main():
 
 
 if __name__ == "__main__":
+    if os.geteuid() == 0:
+        sys.exit('Do not run as root')
     sys.exit(main())
 
 # vi: ts=4 expandtab
diff --git a/tests/cloud_tests/collect.py b/tests/cloud_tests/collect.py
index b44e8bd..4a2422e 100644
--- a/tests/cloud_tests/collect.py
+++ b/tests/cloud_tests/collect.py
@@ -120,6 +120,7 @@ def collect_image(args, platform, os_name):
     os_config = config.load_os_config(
         platform.platform_name, os_name, require_enabled=True,
         feature_overrides=args.feature_override)
+    LOG.debug('os config: %s', os_config)
     component = PlatformComponent(
         partial(images.get_image, platform, os_config))
 
@@ -144,6 +145,8 @@ def collect_platform(args, platform_name):
 
     platform_config = config.load_platform_config(
         platform_name, require_enabled=True)
+    platform_config['data_dir'] = args.data_dir
+    LOG.debug('platform config: %s', platform_config)
     component = PlatformComponent(
         partial(platforms.get_platform, platform_name, platform_config))
 
diff --git a/tests/cloud_tests/config.py b/tests/cloud_tests/config.py
index 4d5dc80..52fc2bd 100644
--- a/tests/cloud_tests/config.py
+++ b/tests/cloud_tests/config.py
@@ -112,6 +112,7 @@ def load_os_config(platform_name, os_name, require_enabled=False,
     feature_conf = main_conf['features']
     feature_groups = conf.get('feature_groups', [])
     overrides = merge_config(get(conf, 'features'), feature_overrides)
+    conf['arch'] = c_util.get_architecture()
     conf['features'] = merge_feature_groups(
         feature_conf, feature_groups, overrides)
 
diff --git a/tests/cloud_tests/images/kvm.py b/tests/cloud_tests/images/kvm.py
new file mode 100644
index 0000000..008acef
--- /dev/null
+++ b/tests/cloud_tests/images/kvm.py
@@ -0,0 +1,75 @@
+# This file is part of cloud-init. See LICENSE file for license information.
+
+"""KVM Image Base Class."""
+
+from tests.cloud_tests.images import base
+from tests.cloud_tests.snapshots import kvm as kvm_snapshot
+
+
+class KVMImage(base.Image):
+    """KVM backed image."""
+
+    platform_name = "kvm"
+
+    def __init__(self, platform, config, img_path):
+        """Set up image.
+
+        @param platform: platform object
+        @param config: image configuration
+        """
+        self.modified = False
+        self._instance = None
+        self._img_path = img_path
+
+        super(KVMImage, self).__init__(platform, config)
+
+    @property
+    def instance(self):
+        """Property function."""
+        if not self._instance:
+            self._instance = self.platform.create_image(
+                self.properties, self.config, self.features, self._img_path,
+                image_desc=str(self), use_desc='image-modification')
+        return self._instance
+
+    @property
+    def properties(self):
+        """{} containing: 'arch', 'os', 'version', 'release'."""
+        return {
+            'arch': self.config['arch'],
+            'os': self.config['family'],
+            'release': self.config['release'],
+            'version': self.config['version'],
+        }
+
+    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'."""
+        return self.instance.push_file(local_path, remote_path)
+
+    def run_script(self, *args, **kwargs):
+        """Run script in image, modifying image.
+
+        @return_value: script output
+        """
+        return self.instance.run_script(*args, **kwargs)
+
+    def snapshot(self):
+        """Create snapshot of image, block until done."""
+        instance = self.platform.create_image(
+            self.properties, self.config, self.features,
+            self._img_path, image_desc=str(self), use_desc='snapshot')
+
+        return kvm_snapshot.KVMSnapshot(
+            self.platform, self.properties, self.config,
+            self.features, instance)
+
+    def destroy(self):
+        """Clean up data associated with image."""
+        self._img_path = None
+        super(KVMImage, self).destroy()
+
+# vi: ts=4 expandtab
diff --git a/tests/cloud_tests/instances/base.py b/tests/cloud_tests/instances/base.py
index 959e9cc..d02f809 100644
--- a/tests/cloud_tests/instances/base.py
+++ b/tests/cloud_tests/instances/base.py
@@ -86,16 +86,16 @@ class Instance(object):
         try:
             self.write_data(script_path, script)
             return self.execute(
-                ['/bin/bash', script_path], rcs=rcs, description=description)
+                '/bin/bash %s' % script_path, rcs=rcs, description=description)
         finally:
-            self.execute(['rm', script_path], rcs=rcs)
+            self.execute('rm %s' % script_path, rcs=rcs)
 
     def tmpfile(self):
         """Get a tmp file in the target.
 
         @return_value: path to new file in target
         """
-        return self.execute(['mktemp'])[0].strip()
+        return self.execute('mktemp')[0].strip()
 
     def console_log(self):
         """Instance console.
@@ -137,9 +137,8 @@ class Instance(object):
             tests.append(self.config['cloud_init_ready_script'])
 
         formatted_tests = ' && '.join(clean_test(t) for t in tests)
-        test_cmd = ('for ((i=0;i<{time};i++)); do {test} && exit 0; sleep 1; '
-                    'done; exit 1;').format(time=time, test=formatted_tests)
-        cmd = ['/bin/bash', '-c', test_cmd]
+        cmd = ('for ((i=0;i<{time};i++)); do {test} && exit 0; sleep 1; '
+               'done; exit 1;').format(time=time, test=formatted_tests)
 
         if self.execute(cmd, rcs=(0, 1))[-1] != 0:
             raise OSError('timeout: after {}s system not started'.format(time))
diff --git a/tests/cloud_tests/instances/kvm.py b/tests/cloud_tests/instances/kvm.py
new file mode 100644
index 0000000..cb8b4cc
--- /dev/null
+++ b/tests/cloud_tests/instances/kvm.py
@@ -0,0 +1,220 @@
+# This file is part of cloud-init. See LICENSE file for license information.
+
+"""Base KVM instance."""
+import os
+import paramiko
+import shlex
+import socket
+import subprocess
+import time
+
+from cloudinit import util as c_util
+from tests.cloud_tests.instances import base
+from tests.cloud_tests import util
+
+
+class KVMInstance(base.Instance):
+    """KVM backed instance."""
+
+    platform_name = "kvm"
+
+    def __init__(self, platform, name, properties, config, features,
+                 user_data, meta_data):
+        """Set up instance.
+
+        @param platform: platform object
+        @param name: image path
+        @param properties: image properties
+        @param config: image config
+        @param features: supported feature flags
+        """
+        self.user_data = user_data
+        self.meta_data = meta_data
+        self.ssh_key_file = os.path.join(platform.config['data_dir'],
+                                         platform.config['private_key'])
+        self.ssh_port = None
+        self.pid = None
+
+        super(KVMInstance, self).__init__(
+            platform, name, properties, config, features)
+
+    def destroy(self):
+        """Clean up instance."""
+        if self.pid:
+            c_util.subp(['kill', '-9', self.pid])
+        super(KVMInstance, self).destroy()
+
+    def execute(self, command, stdout=None, stderr=None, env={},
+                rcs=None, description=None):
+        """Execute command in instance.
+
+        Assumes functional networking and execution as root with the
+        target filesystem being available at /.
+
+        @param command: the command to execute as root inside the image
+        @param stdout, stderr: file handles to write output and error to
+        @param env: environment variables
+        @param rcs: allowed return codes from command
+        @param description: purpose of command
+        @return_value: tuple containing stdout data, stderr data, exit code
+        """
+        if self.pid:
+            out, err, exit = self.ssh(command)
+        else:
+            out, err, exit = self.mount_image_callback(command)
+
+        # write data to file descriptors if needed
+        if stdout:
+            stdout.write(out.readlines())
+        if stderr:
+            stderr.write(err.readlines())
+
+        # if the command exited with a code not allowed in rcs, then fail
+        if exit not in (rcs if rcs else (0,)):
+            error_desc = ('Failed command to: {}'.format(description)
+                          if description else None)
+            raise util.InTargetExecuteError(
+                out, err, exit, command, self.name, error_desc)
+
+        return (out, err, exit)
+
+    def mount_image_callback(self, command,):
+        """Run mount-image-callback."""
+        img_shell = subprocess.Popen(['sudo', 'mount-image-callback',
+                                      '--system-mounts', '--system-resolvconf',
+                                      self.name, 'chroot', '_MOUNTPOINT_',
+                                      '/bin/sh'],
+                                     stdin=subprocess.PIPE,
+                                     stdout=subprocess.PIPE,
+                                     stderr=subprocess.PIPE)
+        out, err = img_shell.communicate(command.encode())
+        exit = img_shell.returncode
+        img_shell.stdin.close()
+        img_shell.wait()
+
+        return out, err, exit
+
+    def generate_seed(self, tmpdir):
+        """Generate nocloud seed from user-data"""
+        seed_file = os.path.join(tmpdir, 'seed.img')
+        user_data_file = os.path.join(tmpdir, 'user_data')
+
+        if os.path.exists(seed_file):
+            os.remove(seed_file)
+        if os.path.exists(user_data_file):
+            os.remove(user_data_file)
+
+        with open(user_data_file, "w") as ud_file:
+            ud_file.write(self.user_data)
+
+        c_util.subp(['cloud-localds', seed_file, user_data_file])
+
+        return seed_file
+
+    def get_free_port(self):
+        """Get a free port assigned by the kernel."""
+        s = socket.socket()
+        s.bind(('', 0))
+        num = s.getsockname()[1]
+        s.close()
+        return num
+
+    def push_file(self, local_path, remote_path, description=''):
+        """Copy file at 'local_path' to instance at 'remote_path'.
+
+        If we have a pid then SSH is up, otherwise, use
+        mount-image-callback.
+
+        @param local_path: path on local instance
+        @param remote_path: path on remote instance
+        """
+        if self.pid:
+            super(KVMInstance, self).push_file()
+        else:
+            command = ('sudo mount-image-callback --system-mounts '
+                       '--system-resolvconf %s -- chroot _MOUNTPOINT_ '
+                       '/bin/sh -ec "cat > %s" < %s'
+                       % (self.name, remote_path, local_path))
+            img_shell = subprocess.Popen(command, shell=True,
+                                         stdin=subprocess.PIPE,
+                                         stdout=subprocess.PIPE,
+                                         stderr=subprocess.PIPE)
+            out, err = img_shell.communicate()
+            img_shell.stdin.close()
+            img_shell.wait()
+
+    def sftp_put(self, path, data):
+        """SFTP put a file."""
+        client = self._ssh_connect()
+        sftp = client.open_sftp()
+
+        with sftp.open(path, 'w') as f:
+            f.write(data)
+
+        client.close()
+
+    def ssh(self, command):
+        """Run a command via SSH."""
+        client = self._ssh_connect()
+        _, out, err = client.exec_command(command)
+        exit = out.channel.recv_exit_status()
+        out = ''.join(out.readlines())
+        err = ''.join(err.readlines())
+        client.close()
+
+        return out, err, exit
+
+    def _ssh_connect(self, hostname='localhost', username='ubuntu',
+                     banner_timeout=120, retry_attempts=30):
+        """Connect via SSH."""
+        private_key = paramiko.RSAKey.from_private_key_file(self.ssh_key_file)
+        client = paramiko.SSHClient()
+        client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
+        while retry_attempts:
+            try:
+                client.connect(hostname=hostname, username=username,
+                               port=self.ssh_port, pkey=private_key,
+                               banner_timeout=banner_timeout)
+                break
+            except paramiko.SSHException:
+                time.sleep(1)
+                retry_attempts = retry_attempts - 1
+
+        return client
+
+    def start(self, wait=True, wait_for_cloud_init=False):
+        """Start instance."""
+        tmpdir = self.platform.config['data_dir']
+        pid_file = os.path.join(tmpdir, 'pid')
+        seed = self.generate_seed(tmpdir)
+        self.ssh_port = self.get_free_port()
+
+        cmd = ('qemu-system-x86_64 -enable-kvm -m 1024 '
+               '-pidfile %s -vnc none '
+               '-drive file=%s,format=qcow2,if=virtio '
+               '-drive file=%s,format=raw,if=virtio '
+               '-device virtio-net-pci,netdev=net00 '
+               '-netdev type=user,id=net00,hostfwd=tcp::%s-:22'
+               % (pid_file, self.name, seed, self.ssh_port))
+
+        subprocess.Popen(shlex.split(cmd), close_fds=True)
+        while not os.path.exists(pid_file):
+            time.sleep(1)
+
+        with open(pid_file, 'r') as pid_f:
+            self.pid = pid_f.readlines()[0].strip()
+
+        time.sleep(10)
+
+        if wait:
+            self._wait_for_system(wait_for_cloud_init)
+
+    def write_data(self, remote_path, data):
+        """Write data to instance filesystem.
+
+        @param remote_path: path in instance
+        @param data: data to write, either str or bytes
+        """
+        self.sftp_put(remote_path, data)
+
+# vi: ts=4 expandtab
diff --git a/tests/cloud_tests/instances/lxd.py b/tests/cloud_tests/instances/lxd.py
index b9c2cc6..c3a7c13 100644
--- a/tests/cloud_tests/instances/lxd.py
+++ b/tests/cloud_tests/instances/lxd.py
@@ -48,6 +48,7 @@ class LXDInstance(base.Instance):
         """
         # ensure instance is running and execute the command
         self.start()
+        command = ['/bin/bash', '-c'] + [command]
         res = self.pylxd_container.execute(command, environment=env)
 
         # get out, exit and err from pylxd return
diff --git a/tests/cloud_tests/platforms.yaml b/tests/cloud_tests/platforms.yaml
index b91834a..7bdc1f5 100644
--- a/tests/cloud_tests/platforms.yaml
+++ b/tests/cloud_tests/platforms.yaml
@@ -8,6 +8,10 @@ default_platform_config:
     create_instance_timeout: 60
 
 platforms:
+    kvm:
+        enabled: true
+        private_key: id_rsa
+        public_key: id_rsa.pub
     lxd:
         enabled: true
         # overrides for image templates
diff --git a/tests/cloud_tests/platforms/__init__.py b/tests/cloud_tests/platforms/__init__.py
index 443f6d4..7890dae 100644
--- a/tests/cloud_tests/platforms/__init__.py
+++ b/tests/cloud_tests/platforms/__init__.py
@@ -2,10 +2,12 @@
 
 """Main init."""
 
+from tests.cloud_tests.platforms import kvm
 from tests.cloud_tests.platforms import lxd
 
 PLATFORMS = {
     'lxd': lxd.LXDPlatform,
+    'kvm': kvm.KVMPlatform,
 }
 
 
diff --git a/tests/cloud_tests/platforms/kvm.py b/tests/cloud_tests/platforms/kvm.py
new file mode 100644
index 0000000..83ca4d6
--- /dev/null
+++ b/tests/cloud_tests/platforms/kvm.py
@@ -0,0 +1,96 @@
+# This file is part of cloud-init. See LICENSE file for license information.
+
+"""Base KVM platform."""
+import glob
+import os
+import shutil
+
+from simplestreams import filters
+from simplestreams import mirrors
+from simplestreams import objectstores
+from simplestreams import util as s_util
+
+from cloudinit import util as c_util
+from tests.cloud_tests.images import kvm as kvm_image
+from tests.cloud_tests.instances import kvm as kvm_instance
+from tests.cloud_tests.platforms import base
+from tests.cloud_tests import util
+
+
+class KVMPlatform(base.Platform):
+    """LXD test platform."""
+
+    platform_name = 'kvm'
+
+    def __init__(self, config):
+        """Set up platform."""
+        super(KVMPlatform, self).__init__(config)
+
+    def get_image(self, img_conf):
+        """Get image using specified image configuration.
+
+        @param img_conf: configuration for image
+        @return_value: cloud_tests.images instance
+        """
+        filter = filters.get_filters(['arch=%s' % c_util.get_architecture(),
+                                      'release=%s' % img_conf['release'],
+                                      'ftype=disk1.img'])
+        mirror_config = {'filters': filter,
+                         'keep_items': False,
+                         'max_items': 1,
+                         'checksumming_reader': True,
+                         'item_download': True
+                         }
+
+        def policy(content, path):
+            return s_util.read_signed(content, keyring=img_conf['keyring'])
+
+        smirror = mirrors.UrlMirrorReader(img_conf['mirror_url'],
+                                          policy=policy)
+        tstore = objectstores.FileStore(img_conf['mirror_dir'])
+        tmirror = mirrors.ObjectFilterMirror(config=mirror_config,
+                                             objectstore=tstore)
+        tmirror.sync(smirror, img_conf['mirror_path'])
+
+        search_d = os.path.join(img_conf['mirror_dir'], '**',
+                                img_conf['release'], '**', '*.img')
+
+        images = []
+        for fname in glob.iglob(search_d, recursive=True):
+            images.append(fname)
+
+        if len(images) != 1:
+            raise Exception('No unique images found')
+
+        image = kvm_image.KVMImage(self, img_conf, images[0])
+        if img_conf.get('override_templates', False):
+            image.update_templates(self.config.get('template_overrides', {}),
+                                   self.config.get('template_files', {}))
+        return image
+
+    def create_image(self, properties, config, features,
+                     src_img_path, image_desc=None, use_desc=None,
+                     user_data=None, meta_data=None):
+        """Create an image
+
+        @param src_image_path: image path to launch from
+        @param properties: image properties
+        @param config: image configuration
+        @param features: image features
+        @param image_desc: description of image being launched
+        @param use_desc: description of container's use
+        @return_value: cloud_tests.instances instance
+        """
+        name = util.gen_instance_name(image_desc=image_desc, use_desc=use_desc)
+        img_path = os.path.join(self.config['data_dir'], name + '.qcow2')
+        shutil.copy2(src_img_path, img_path)
+
+        # create copy of the latest image, return that as an instance
+        return kvm_instance.KVMInstance(self, img_path, properties, config,
+                                        features, user_data, meta_data)
+
+    def destroy(self):
+        """Clean up platform data."""
+        super(KVMPlatform, self).destroy()
+
+# vi: ts=4 expandtab
diff --git a/tests/cloud_tests/releases.yaml b/tests/cloud_tests/releases.yaml
index 45deb58..02993d6 100644
--- a/tests/cloud_tests/releases.yaml
+++ b/tests/cloud_tests/releases.yaml
@@ -27,7 +27,13 @@ default_release_config:
         # features groups and additional feature settings
         feature_groups: []
         features: {}
-
+    kvm:
+        mirror_url: https://cloud-images.ubuntu.com/daily
+        mirror_path: streams/v1/index.sjson
+        mirror_dir: '/tmp/cloud_test_mirror'
+        keyring: /usr/share/keyrings/ubuntu-cloudimage-keyring.gpg
+        setup_overrides: null
+        override_templates: false
     # lxd specific default configuration options
     lxd:
         # default sstreams server to use for lxd image retrieval
@@ -121,6 +127,9 @@ releases:
         # EOL: Jul 2018
         default:
             enabled: true
+            release: artful
+            version: 17.10
+            family: ubuntu
             feature_groups:
                 - base
                 - debian_base
@@ -134,6 +143,9 @@ releases:
         # EOL: Jan 2018
         default:
             enabled: true
+            release: zesty
+            version: 17.04
+            family: ubuntu
             feature_groups:
                 - base
                 - debian_base
@@ -147,6 +159,9 @@ releases:
         # EOL: Jul 2017
         default:
             enabled: true
+            release: yakkety
+            version: 16.10
+            family: ubuntu
             feature_groups:
                 - base
                 - debian_base
@@ -160,6 +175,9 @@ releases:
         # EOL: Apr 2021
         default:
             enabled: true
+            release: xenial
+            version: 16.04
+            family: ubuntu
             feature_groups:
                 - base
                 - debian_base
@@ -173,6 +191,9 @@ releases:
         # EOL: Apr 2019
         default:
             enabled: true
+            release: trusty
+            version: 14.04
+            family: ubuntu
             feature_groups:
                 - base
                 - debian_base
diff --git a/tests/cloud_tests/setup_image.py b/tests/cloud_tests/setup_image.py
index 8053a09..557b4b5 100644
--- a/tests/cloud_tests/setup_image.py
+++ b/tests/cloud_tests/setup_image.py
@@ -5,6 +5,7 @@
 from functools import partial
 import os
 
+from cloudinit import util as c_util
 from tests.cloud_tests import LOG
 from tests.cloud_tests import stage, util
 
@@ -19,9 +20,9 @@ def installed_package_version(image, package, ensure_installed=True):
     """
     os_family = util.get_os_family(image.properties['os'])
     if os_family == 'debian':
-        cmd = ['dpkg-query', '-W', "--showformat='${Version}'", package]
+        cmd = 'dpkg-query -W --showformat=\'${Version}\' %s' % package
     elif os_family == 'redhat':
-        cmd = ['rpm', '-q', '--queryformat', "'%{VERSION}'", package]
+        cmd = 'rpm -q --queryformat \'%{VERSION}\' %s' % package
     else:
         raise NotImplementedError
 
@@ -50,11 +51,11 @@ def install_deb(args, image):
     remote_path = os.path.join('/tmp', os.path.basename(args.deb))
     image.push_file(args.deb, remote_path)
     cmd = 'dpkg -i {} || apt-get install --yes -f'.format(remote_path)
-    image.execute(['/bin/sh', '-c', cmd], description=msg)
+    image.execute(cmd, description=msg)
 
     # check installed deb version matches package
-    fmt = ['-W', "--showformat='${Version}'"]
-    (out, err, exit) = image.execute(['dpkg-deb'] + fmt + [remote_path])
+    cmd = 'dpkg-deb -W --showformat=\'${Version}\' %s' % remote_path
+    (out, err, exit) = image.execute(cmd)
     expected_version = out.strip()
     found_version = installed_package_version(image, 'cloud-init')
     if expected_version != found_version:
@@ -82,10 +83,10 @@ def install_rpm(args, image):
     LOG.debug(msg)
     remote_path = os.path.join('/tmp', os.path.basename(args.rpm))
     image.push_file(args.rpm, remote_path)
-    image.execute(['rpm', '-U', remote_path], description=msg)
+    image.execute('rpm -U %s' % remote_path, description=msg)
 
-    fmt = ['--queryformat', '"%{VERSION}"']
-    (out, err, exit) = image.execute(['rpm', '-q'] + fmt + [remote_path])
+    cmd = 'rpm -q --queryformat \'${VERSION}\' %s' % remote_path
+    (out, err, exit) = image.execute(cmd)
     expected_version = out.strip()
     found_version = installed_package_version(image, 'cloud-init')
     if expected_version != found_version:
@@ -113,7 +114,7 @@ def upgrade(args, image):
 
     msg = 'upgrading cloud-init'
     LOG.debug(msg)
-    image.execute(['/bin/sh', '-c', cmd], description=msg)
+    image.execute(cmd, description=msg)
 
 
 def upgrade_full(args, image):
@@ -134,7 +135,7 @@ def upgrade_full(args, image):
 
     msg = 'full system upgrade'
     LOG.debug(msg)
-    image.execute(['/bin/sh', '-c', cmd], description=msg)
+    image.execute(cmd, description=msg)
 
 
 def run_script(args, image):
@@ -165,7 +166,7 @@ def enable_ppa(args, image):
     msg = 'enable ppa: "{}" in target'.format(ppa)
     LOG.debug(msg)
     cmd = 'add-apt-repository --yes {} && apt-get update'.format(ppa)
-    image.execute(['/bin/sh', '-c', cmd], description=msg)
+    image.execute(cmd, description=msg)
 
 
 def enable_repo(args, image):
@@ -188,7 +189,16 @@ def enable_repo(args, image):
 
     msg = 'enable repo: "{}" in target'.format(args.repo)
     LOG.debug(msg)
-    image.execute(['/bin/sh', '-c', cmd], description=msg)
+    image.execute(cmd, description=msg)
+
+
+def generate_ssh_keys(args, image):
+    """Generate SSH keys to be used with image."""
+    LOG.info('generating SSH keys')
+    c_util.subp(['ssh-keygen', '-t', 'rsa', '-b', '4096',
+                 '-f', '%s/id_rsa' % args.data_dir, '-P', '',
+                 '-C', 'ubuntu@cloud_test'],
+                capture=True)
 
 
 def setup_image(args, image):
@@ -226,6 +236,7 @@ def setup_image(args, image):
         'set up for {}'.format(image), calls, continue_after_error=False)
     LOG.debug('after setup complete, installed cloud-init version is: %s',
               installed_package_version(image, 'cloud-init'))
+    generate_ssh_keys(args, image)
     return res
 
 # vi: ts=4 expandtab
diff --git a/tests/cloud_tests/snapshots/kvm.py b/tests/cloud_tests/snapshots/kvm.py
new file mode 100644
index 0000000..c15f834
--- /dev/null
+++ b/tests/cloud_tests/snapshots/kvm.py
@@ -0,0 +1,74 @@
+# This file is part of cloud-init. See LICENSE file for license information.
+
+"""Base KVM snapshot."""
+import os
+
+from tests.cloud_tests.snapshots import base
+
+
+class KVMSnapshot(base.Snapshot):
+    """KVM image copy backed snapshot."""
+
+    platform_name = "kvm"
+
+    def __init__(self, platform, properties, config, features,
+                 instance):
+        """Set up snapshot.
+
+        @param platform: platform object
+        @param properties: image properties
+        @param config: image config
+        @param features: supported feature flags
+        """
+        self.instance = instance
+
+        super(KVMSnapshot, self).__init__(
+            platform, properties, config, features)
+
+    def launch(self, user_data, meta_data=None, block=True, start=True,
+               use_desc=None):
+        """Launch instance.
+
+        @param user_data: user-data for the instance
+        @param instance_id: instance-id for the instance
+        @param block: wait until instance is created
+        @param start: start instance and wait until fully started
+        @param use_desc: description of snapshot instance use
+        @return_value: an Instance
+        """
+        key_file = os.path.join(self.platform.config['data_dir'],
+                                self.platform.config['public_key'])
+        user_data = self.inject_ssh_key(user_data, key_file)
+
+        instance = self.platform.create_image(
+            self.properties, self.config, self.features,
+            self.instance.name, image_desc=str(self), use_desc=use_desc,
+            user_data=user_data, meta_data=meta_data)
+
+        if start:
+            instance.start()
+
+        return instance
+
+    def inject_ssh_key(self, user_data, key_file):
+        """Inject the authorized key into the user_data."""
+        with open(key_file) as f:
+            value = f.read()
+
+        key = 'ssh_authorized_keys:'
+        value = '  - %s' % value.strip()
+        user_data = user_data.split('\n')
+        if key in user_data:
+            user_data.insert(user_data.index(key) + 1, '%s' % value)
+        else:
+            user_data.insert(-1, '%s' % key)
+            user_data.insert(-1, '%s' % value)
+
+        return '\n'.join(user_data)
+
+    def destroy(self):
+        """Clean up snapshot data."""
+        self.instance.destroy()
+        super(KVMSnapshot, self).destroy()
+
+# vi: ts=4 expandtab

Follow ups