diff options
author | Scott Moser <smoser@brickies.net> | 2017-12-05 17:05:29 -0500 |
---|---|---|
committer | Scott Moser <smoser@brickies.net> | 2017-12-05 17:05:29 -0500 |
commit | 47016791ca5e97d80e45d3f100bc4e5d0b88627d (patch) | |
tree | 70f9e5bad3b136abeecd6399b077c82a81953220 /tests/cloud_tests/platforms | |
parent | 7acc9e68fafbbd7c56587aebe752ba6ba8c8a3db (diff) | |
download | vyos-cloud-init-47016791ca5e97d80e45d3f100bc4e5d0b88627d.tar.gz vyos-cloud-init-47016791ca5e97d80e45d3f100bc4e5d0b88627d.zip |
tests: consolidate platforms into specific dirs
This groups up each test platform into its own directory rather
than having files spread between four different directories for
one platform. Platforms tend to be worked on one at a time and
so having the platforms together makes more sense than apart.
Diffstat (limited to 'tests/cloud_tests/platforms')
-rw-r--r-- | tests/cloud_tests/platforms/__init__.py | 20 | ||||
-rw-r--r-- | tests/cloud_tests/platforms/images.py | 56 | ||||
-rw-r--r-- | tests/cloud_tests/platforms/instances.py | 77 | ||||
-rw-r--r-- | tests/cloud_tests/platforms/lxd/image.py | 193 | ||||
-rw-r--r-- | tests/cloud_tests/platforms/lxd/instance.py | 157 | ||||
-rw-r--r-- | tests/cloud_tests/platforms/lxd/platform.py (renamed from tests/cloud_tests/platforms/lxd.py) | 14 | ||||
-rw-r--r-- | tests/cloud_tests/platforms/lxd/snapshot.py | 53 | ||||
-rw-r--r-- | tests/cloud_tests/platforms/nocloudkvm/image.py | 89 | ||||
-rw-r--r-- | tests/cloud_tests/platforms/nocloudkvm/instance.py | 179 | ||||
-rw-r--r-- | tests/cloud_tests/platforms/nocloudkvm/platform.py (renamed from tests/cloud_tests/platforms/nocloudkvm.py) | 16 | ||||
-rw-r--r-- | tests/cloud_tests/platforms/nocloudkvm/snapshot.py | 79 | ||||
-rw-r--r-- | tests/cloud_tests/platforms/platforms.py (renamed from tests/cloud_tests/platforms/base.py) | 0 | ||||
-rw-r--r-- | tests/cloud_tests/platforms/snapshots.py | 45 |
13 files changed, 960 insertions, 18 deletions
diff --git a/tests/cloud_tests/platforms/__init__.py b/tests/cloud_tests/platforms/__init__.py index 3490fe87..92ed1627 100644 --- a/tests/cloud_tests/platforms/__init__.py +++ b/tests/cloud_tests/platforms/__init__.py @@ -2,8 +2,8 @@ """Main init.""" -from tests.cloud_tests.platforms import lxd -from tests.cloud_tests.platforms import nocloudkvm +from .lxd import platform as lxd +from .nocloudkvm import platform as nocloudkvm PLATFORMS = { 'nocloud-kvm': nocloudkvm.NoCloudKVMPlatform, @@ -11,6 +11,16 @@ PLATFORMS = { } +def get_image(platform, config): + """Get image from platform object using os_name.""" + return platform.get_image(config) + + +def get_instance(snapshot, *args, **kwargs): + """Get instance from snapshot.""" + return snapshot.launch(*args, **kwargs) + + def get_platform(platform_name, config): """Get the platform object for 'platform_name' and init.""" platform_cls = PLATFORMS.get(platform_name) @@ -18,4 +28,10 @@ def get_platform(platform_name, config): raise ValueError('invalid platform name: {}'.format(platform_name)) return platform_cls(config) + +def get_snapshot(image): + """Get snapshot from image.""" + return image.snapshot() + + # vi: ts=4 expandtab diff --git a/tests/cloud_tests/platforms/images.py b/tests/cloud_tests/platforms/images.py new file mode 100644 index 00000000..d503108a --- /dev/null +++ b/tests/cloud_tests/platforms/images.py @@ -0,0 +1,56 @@ +# This file is part of cloud-init. See LICENSE file for license information. + +"""Base class for images.""" + +from ..util import TargetBase + + +class Image(TargetBase): + """Base class for images.""" + + platform_name = None + + def __init__(self, platform, config): + """Set up image. + + @param platform: platform object + @param config: image configuration + """ + self.platform = platform + self.config = config + + 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 + + @property + def features(self): + """Feature flags supported by this image. + + @return_value: list of feature names + """ + return [k for k, v in self.config.get('features', {}).items() if v] + + @property + def setup_overrides(self): + """Setup options that need to be overridden for the image. + + @return_value: dictionary to update args with + """ + # NOTE: more sophisticated options may be requied at some point + return self.config.get('setup_overrides', {}) + + def snapshot(self): + """Create snapshot of image, block until done.""" + raise NotImplementedError + + def destroy(self): + """Clean up data associated with image.""" + pass + +# vi: ts=4 expandtab diff --git a/tests/cloud_tests/platforms/instances.py b/tests/cloud_tests/platforms/instances.py new file mode 100644 index 00000000..8c59d62c --- /dev/null +++ b/tests/cloud_tests/platforms/instances.py @@ -0,0 +1,77 @@ +# This file is part of cloud-init. See LICENSE file for license information. + +"""Base instance.""" + +from ..util import TargetBase + + +class Instance(TargetBase): + """Base instance object.""" + + platform_name = None + + def __init__(self, platform, name, properties, config, features): + """Set up instance. + + @param platform: platform object + @param name: hostname of instance + @param properties: image properties + @param config: image config + @param features: supported feature flags + """ + self.platform = platform + self.name = name + self.properties = properties + self.config = config + self.features = features + self._tmp_count = 0 + + def console_log(self): + """Instance console. + + @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, wait_for_cloud_init=False): + """Start instance.""" + raise NotImplementedError + + def destroy(self): + """Clean up instance.""" + pass + + def _wait_for_system(self, wait_for_cloud_init): + """Wait until system has fully booted and cloud-init has finished. + + @param wait_time: maximum time to wait + @return_value: None, may raise OSError if wait_time exceeded + """ + def clean_test(test): + """Clean formatting for system ready test testcase.""" + return ' '.join(l for l in test.strip().splitlines() + if not l.lstrip().startswith('#')) + + time = self.config['boot_timeout'] + tests = [self.config['system_ready_script']] + if wait_for_cloud_init: + tests.append(self.config['cloud_init_ready_script']) + + formatted_tests = ' && '.join(clean_test(t) for t in tests) + cmd = ('i=0; while [ $i -lt {time} ] && i=$(($i+1)); 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)) + + +# vi: ts=4 expandtab diff --git a/tests/cloud_tests/platforms/lxd/image.py b/tests/cloud_tests/platforms/lxd/image.py new file mode 100644 index 00000000..b5de1f52 --- /dev/null +++ b/tests/cloud_tests/platforms/lxd/image.py @@ -0,0 +1,193 @@ +# This file is part of cloud-init. See LICENSE file for license information. + +"""LXD Image Base Class.""" + +import os +import shutil +import tempfile + +from ..images import Image +from .snapshot import LXDSnapshot +from cloudinit import util as c_util +from tests.cloud_tests import util + + +class LXDImage(Image): + """LXD backed image.""" + + platform_name = "lxd" + + def __init__(self, platform, config, pylxd_image): + """Set up image. + + @param platform: platform object + @param config: image configuration + """ + self.modified = False + self._img_instance = None + self._pylxd_image = None + self.pylxd_image = pylxd_image + super(LXDImage, self).__init__(platform, config) + + @property + def pylxd_image(self): + """Property function.""" + if self._pylxd_image: + self._pylxd_image.sync() + return self._pylxd_image + + @pylxd_image.setter + def pylxd_image(self, pylxd_image): + if self._img_instance: + self._instance.destroy() + self._img_instance = None + if (self._pylxd_image and + (self._pylxd_image is not pylxd_image) and + (not self.config.get('cache_base_image') or self.modified)): + self._pylxd_image.delete(wait=True) + self.modified = False + self._pylxd_image = pylxd_image + + @property + def _instance(self): + """Internal use only, returns a instance + + This starts an lxc instance from the image, so it is "dirty". + Better would be some way to modify this "at rest". + lxc-pstart would be an option.""" + if not self._img_instance: + self._img_instance = self.platform.launch_container( + self.properties, self.config, self.features, + use_desc='image-modification', image_desc=str(self), + image=self.pylxd_image.fingerprint) + self._img_instance.start() + return self._img_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 export_image(self, output_dir): + """Export image from lxd image store to (split) tarball on disk. + + @param output_dir: dir to store tarballs in + @return_value: tuple of path to metadata tarball and rootfs tarball + """ + # pylxd's image export feature doesn't do split exports, so use cmdline + c_util.subp(['lxc', 'image', 'export', self.pylxd_image.fingerprint, + output_dir], capture=True) + tarballs = [p for p in os.listdir(output_dir) if p.endswith('tar.xz')] + metadata = os.path.join( + output_dir, next(p for p in tarballs if p.startswith('meta-'))) + rootfs = os.path.join( + output_dir, next(p for p in tarballs if not p.startswith('meta-'))) + return (metadata, rootfs) + + def import_image(self, metadata, rootfs): + """Import image to lxd image store from (split) tarball on disk. + + Note, this will replace and delete the current pylxd_image + + @param metadata: metadata tarball + @param rootfs: rootfs tarball + @return_value: imported image fingerprint + """ + alias = util.gen_instance_name( + image_desc=str(self), use_desc='update-metadata') + c_util.subp(['lxc', 'image', 'import', metadata, rootfs, + '--alias', alias], capture=True) + self.pylxd_image = self.platform.query_image_by_alias(alias) + return self.pylxd_image.fingerprint + + def update_templates(self, template_config, template_data): + """Update the image's template configuration. + + Note, this will replace and delete the current pylxd_image + + @param template_config: config overrides for template metadata + @param template_data: template data to place into templates/ + """ + # set up tmp files + export_dir = tempfile.mkdtemp(prefix='cloud_test_util_') + extract_dir = tempfile.mkdtemp(prefix='cloud_test_util_') + new_metadata = os.path.join(export_dir, 'new-meta.tar.xz') + metadata_yaml = os.path.join(extract_dir, 'metadata.yaml') + template_dir = os.path.join(extract_dir, 'templates') + + try: + # extract old data + (metadata, rootfs) = self.export_image(export_dir) + shutil.unpack_archive(metadata, extract_dir) + + # update metadata + metadata = c_util.read_conf(metadata_yaml) + templates = metadata.get('templates', {}) + templates.update(template_config) + metadata['templates'] = templates + util.yaml_dump(metadata, metadata_yaml) + + # write out template files + for name, content in template_data.items(): + path = os.path.join(template_dir, name) + c_util.write_file(path, content) + + # store new data, mark new image as modified + util.flat_tar(new_metadata, extract_dir) + self.import_image(new_metadata, rootfs) + self.modified = True + + finally: + # remove tmpfiles + shutil.rmtree(export_dir) + shutil.rmtree(extract_dir) + + def _execute(self, *args, **kwargs): + """Execute command in image, modifying image.""" + 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.""" + # get empty user data to pass in to instance + # if overrides for user data provided, use them + empty_userdata = util.update_user_data( + {}, self.config.get('user_data_overrides', {})) + conf = {'user.user-data': empty_userdata} + # clone current instance + instance = self.platform.launch_container( + self.properties, self.config, self.features, + container=self._instance.name, image_desc=str(self), + use_desc='snapshot', container_config=conf) + # wait for cloud-init before boot_clean_script is run to ensure + # /var/lib/cloud is removed cleanly + instance.start(wait=True, wait_for_cloud_init=True) + if self.config.get('boot_clean_script'): + instance.run_script(self.config.get('boot_clean_script')) + # freeze current instance and return snapshot + instance.freeze() + return LXDSnapshot(self.platform, self.properties, self.config, + self.features, instance) + + def destroy(self): + """Clean up data associated with image.""" + self.pylxd_image = None + super(LXDImage, self).destroy() + +# vi: ts=4 expandtab diff --git a/tests/cloud_tests/platforms/lxd/instance.py b/tests/cloud_tests/platforms/lxd/instance.py new file mode 100644 index 00000000..0d697c05 --- /dev/null +++ b/tests/cloud_tests/platforms/lxd/instance.py @@ -0,0 +1,157 @@ +# This file is part of cloud-init. See LICENSE file for license information. + +"""Base LXD instance.""" + +import os +import shutil +from tempfile import mkdtemp + +from ..instances import Instance + + +class LXDInstance(Instance): + """LXD container backed instance.""" + + platform_name = "lxd" + + def __init__(self, platform, name, properties, config, features, + pylxd_container): + """Set up instance. + + @param platform: platform object + @param name: hostname of instance + @param properties: image properties + @param config: image config + @param features: supported feature flags + """ + self._pylxd_container = pylxd_container + super(LXDInstance, self).__init__( + platform, name, properties, config, features) + self.tmpd = mkdtemp(prefix="%s-%s" % (type(self).__name__, name)) + self._setup_console_log() + + @property + def pylxd_container(self): + """Property function.""" + self._pylxd_container.sync() + return self._pylxd_container + + def _setup_console_log(self): + logf = os.path.join(self.tmpd, "console.log") + + # doing this ensures we can read it. Otherwise it ends up root:root. + with open(logf, "w") as fp: + fp.write("# %s\n" % self.name) + + cfg = "lxc.console.logfile=%s" % logf + orig = self._pylxd_container.config.get('raw.lxc', "") + if orig: + orig += "\n" + self._pylxd_container.config['raw.lxc'] = orig + cfg + self._pylxd_container.save() + self._console_log_file = logf + + def _execute(self, command, stdin=None, env=None): + if env is None: + env = {} + + if stdin is not None: + # pylxd does not support input to execute. + # https://github.com/lxc/pylxd/issues/244 + # + # The solution here is write a tmp file in the container + # and then execute a shell that sets it standard in to + # be from that file, removes it, and calls the comand. + tmpf = self.tmpfile() + self.write_data(tmpf, stdin) + ncmd = 'exec <"{tmpf}"; rm -f "{tmpf}"; exec "$@"' + command = (['sh', '-c', ncmd.format(tmpf=tmpf), 'stdinhack'] + + list(command)) + + # ensure instance is running and execute the command + self.start() + # execute returns a ContainerExecuteResult, named tuple + # (exit_code, stdout, stderr) + res = self.pylxd_container.execute(command, environment=env) + + # get out, exit and err from pylxd return + if not hasattr(res, 'exit_code'): + # pylxd 2.1.3 and earlier only return out and err, no exit + raise RuntimeError( + "No 'exit_code' in pylxd.container.execute return.\n" + "pylxd > 2.2 is required.") + + return res.stdout, res.stderr, res.exit_code + + def read_data(self, remote_path, decode=False): + """Read data from instance filesystem. + + @param remote_path: path in instance + @param decode: decode data before returning. + @return_value: content of remote_path as bytes if 'decode' is False, + and as string if 'decode' is True. + """ + data = self.pylxd_container.files.get(remote_path) + return data.decode() if decode else data + + def write_data(self, remote_path, data): + """Write data to instance filesystem. + + @param remote_path: path in instance + @param data: data to write in bytes + """ + self.pylxd_container.files.put(remote_path, data) + + def console_log(self): + """Console log. + + @return_value: bytes of this instance’s console + """ + if not os.path.exists(self._console_log_file): + raise NotImplementedError( + "Console log '%s' does not exist. If this is a remote " + "lxc, then this is really NotImplementedError. If it is " + "A local lxc, then this is a RuntimeError." + "https://github.com/lxc/lxd/issues/1129") + with open(self._console_log_file, "rb") as fp: + return fp.read() + + 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_for_cloud_init=False): + """Start instance.""" + if self.pylxd_container.status != 'Running': + self.pylxd_container.start(wait=wait) + if wait: + self._wait_for_system(wait_for_cloud_init) + + 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)) + shutil.rmtree(self.tmpd) + super(LXDInstance, self).destroy() + +# vi: ts=4 expandtab diff --git a/tests/cloud_tests/platforms/lxd.py b/tests/cloud_tests/platforms/lxd/platform.py index ead0955b..6a016929 100644 --- a/tests/cloud_tests/platforms/lxd.py +++ b/tests/cloud_tests/platforms/lxd/platform.py @@ -4,15 +4,15 @@ 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 ..platforms import Platform +from .image import LXDImage +from .instance import LXDInstance from tests.cloud_tests import util DEFAULT_SSTREAMS_SERVER = "https://images.linuxcontainers.org:8443" -class LXDPlatform(base.Platform): +class LXDPlatform(Platform): """LXD test platform.""" platform_name = 'lxd' @@ -33,7 +33,7 @@ class LXDPlatform(base.Platform): pylxd_image = self.client.images.create_from_simplestreams( img_conf.get('sstreams_server', DEFAULT_SSTREAMS_SERVER), img_conf['alias']) - image = lxd_image.LXDImage(self, img_conf, pylxd_image) + image = LXDImage(self, img_conf, pylxd_image) if img_conf.get('override_templates', False): image.update_templates(self.config.get('template_overrides', {}), self.config.get('template_files', {})) @@ -69,8 +69,8 @@ class LXDPlatform(base.Platform): 'source': ({'type': 'image', 'fingerprint': image} if image else {'type': 'copy', 'source': container}) }, wait=block) - return lxd_instance.LXDInstance(self, container.name, properties, - config, features, container) + return LXDInstance(self, container.name, properties, config, features, + container) def container_exists(self, container_name): """Check if container with name 'container_name' exists. diff --git a/tests/cloud_tests/platforms/lxd/snapshot.py b/tests/cloud_tests/platforms/lxd/snapshot.py new file mode 100644 index 00000000..b524644f --- /dev/null +++ b/tests/cloud_tests/platforms/lxd/snapshot.py @@ -0,0 +1,53 @@ +# This file is part of cloud-init. See LICENSE file for license information. + +"""Base LXD snapshot.""" + +from ..snapshots import Snapshot + + +class LXDSnapshot(Snapshot): + """LXD image copy backed snapshot.""" + + platform_name = "lxd" + + def __init__(self, platform, properties, config, features, + pylxd_frozen_instance): + """Set up snapshot. + + @param platform: platform object + @param properties: image properties + @param config: image config + @param features: supported feature flags + """ + self.pylxd_frozen_instance = pylxd_frozen_instance + super(LXDSnapshot, 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 + """ + inst_config = {'user.user-data': user_data} + if meta_data: + inst_config['user.meta-data'] = meta_data + instance = self.platform.launch_container( + self.properties, self.config, self.features, block=block, + image_desc=str(self), container=self.pylxd_frozen_instance.name, + use_desc=use_desc, container_config=inst_config) + if start: + instance.start() + return instance + + def destroy(self): + """Clean up snapshot data.""" + self.pylxd_frozen_instance.destroy() + super(LXDSnapshot, self).destroy() + +# vi: ts=4 expandtab diff --git a/tests/cloud_tests/platforms/nocloudkvm/image.py b/tests/cloud_tests/platforms/nocloudkvm/image.py new file mode 100644 index 00000000..09ff2a3b --- /dev/null +++ b/tests/cloud_tests/platforms/nocloudkvm/image.py @@ -0,0 +1,89 @@ +# This file is part of cloud-init. See LICENSE file for license information. + +"""NoCloud KVM Image Base Class.""" + +from cloudinit import util as c_util + +import os +import shutil +import tempfile + +from ..images import Image +from .snapshot import NoCloudKVMSnapshot + + +class NoCloudKVMImage(Image): + """NoCloud KVM backed image.""" + + platform_name = "nocloud-kvm" + + def __init__(self, platform, config, orig_img_path): + """Set up image. + + @param platform: platform object + @param config: image configuration + @param img_path: path to the image + """ + self.modified = False + self._workd = tempfile.mkdtemp(prefix='NoCloudKVMImage') + self._orig_img_path = orig_img_path + self._img_path = os.path.join(self._workd, + os.path.basename(self._orig_img_path)) + + c_util.subp(['qemu-img', 'create', '-f', 'qcow2', + '-b', orig_img_path, self._img_path]) + + super(NoCloudKVMImage, self).__init__(platform, config) + + @property + def properties(self): + """Dictionary 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, command, stdin=None, env=None): + """Execute command in image, modifying image.""" + return self.mount_image_callback(command, stdin=stdin, env=env) + + def mount_image_callback(self, command, stdin=None, env=None): + """Run mount-image-callback.""" + + env_args = [] + if env: + env_args = ['env'] + ["%s=%s" for k, v in env.items()] + + mic_chroot = ['sudo', 'mount-image-callback', '--system-mounts', + '--system-resolvconf', self._img_path, + '--', 'chroot', '_MOUNTPOINT_'] + try: + out, err = c_util.subp(mic_chroot + env_args + list(command), + data=stdin, decode=False) + return (out, err, 0) + except c_util.ProcessExecutionError as e: + return (e.stdout, e.stderr, e.exit_code) + + def snapshot(self): + """Create snapshot of image, block until done.""" + if not self._img_path: + raise RuntimeError() + + return NoCloudKVMSnapshot(self.platform, self.properties, self.config, + self.features, self._img_path) + + def destroy(self): + """Unset path to signal image is no longer used. + + The removal of the images and all other items is handled by the + framework. In some cases we want to keep the images, so let the + framework decide whether to keep or destroy everything. + """ + self._img_path = None + shutil.rmtree(self._workd) + + super(NoCloudKVMImage, self).destroy() + +# vi: ts=4 expandtab diff --git a/tests/cloud_tests/platforms/nocloudkvm/instance.py b/tests/cloud_tests/platforms/nocloudkvm/instance.py new file mode 100644 index 00000000..a87d76a6 --- /dev/null +++ b/tests/cloud_tests/platforms/nocloudkvm/instance.py @@ -0,0 +1,179 @@ +# This file is part of cloud-init. See LICENSE file for license information. + +"""Base NoCloud KVM instance.""" + +import os +import paramiko +import socket +import subprocess +import time + +from ..instances import Instance +from cloudinit import util as c_util +from tests.cloud_tests import util + +# This domain contains reverse lookups for hostnames that are used. +# The primary reason is so sudo will return quickly when it attempts +# to look up the hostname. i9n is just short for 'integration'. +# see also bug 1730744 for why we had to do this. +CI_DOMAIN = "i9n.cloud-init.io" + + +class NoCloudKVMInstance(Instance): + """NoCloud KVM backed instance.""" + + platform_name = "nocloud-kvm" + _ssh_client = None + + def __init__(self, platform, name, image_path, properties, config, + features, user_data, meta_data): + """Set up instance. + + @param platform: platform object + @param name: image path + @param image_path: path to disk image to boot. + @param properties: dictionary of properties + @param config: dictionary of configuration values + @param features: dictionary of 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 + self.pid_file = None + self.console_file = None + self.disk = image_path + + super(NoCloudKVMInstance, self).__init__( + platform, name, properties, config, features) + + def destroy(self): + """Clean up instance.""" + if self.pid: + try: + c_util.subp(['kill', '-9', self.pid]) + except util.ProcessExectuionError: + pass + + if self.pid_file: + os.remove(self.pid_file) + + self.pid = None + if self._ssh_client: + self._ssh_client.close() + self._ssh_client = None + + super(NoCloudKVMInstance, self).destroy() + + def _execute(self, command, stdin=None, env=None): + env_args = [] + if env: + env_args = ['env'] + ["%s=%s" for k, v in env.items()] + + return self.ssh(['sudo'] + env_args + list(command), stdin=stdin) + + def generate_seed(self, tmpdir): + """Generate nocloud seed from user-data""" + seed_file = os.path.join(tmpdir, '%s_seed.img' % self.name) + user_data_file = os.path.join(tmpdir, '%s_user_data' % self.name) + + 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 ssh(self, command, stdin=None): + """Run a command via SSH.""" + client = self._ssh_connect() + + cmd = util.shell_pack(command) + try: + fp_in, fp_out, fp_err = client.exec_command(cmd) + channel = fp_in.channel + if stdin is not None: + fp_in.write(stdin) + fp_in.close() + + channel.shutdown_write() + rc = channel.recv_exit_status() + return (fp_out.read(), fp_err.read(), rc) + except paramiko.SSHException as e: + raise util.InTargetExecuteError( + b'', b'', -1, command, self.name, reason=e) + + def _ssh_connect(self, hostname='localhost', username='ubuntu', + banner_timeout=120, retry_attempts=30): + """Connect via SSH.""" + if self._ssh_client: + return self._ssh_client + + 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) + self._ssh_client = client + return client + except (paramiko.SSHException, TypeError): + time.sleep(1) + retry_attempts = retry_attempts - 1 + + error_desc = 'Failed command to: %s@%s:%s' % (username, hostname, + self.ssh_port) + raise util.InTargetExecuteError('', '', -1, 'ssh connect', + self.name, error_desc) + + def start(self, wait=True, wait_for_cloud_init=False): + """Start instance.""" + tmpdir = self.platform.config['data_dir'] + seed = self.generate_seed(tmpdir) + self.pid_file = os.path.join(tmpdir, '%s.pid' % self.name) + self.console_file = os.path.join(tmpdir, '%s-console.log' % self.name) + self.ssh_port = self.get_free_port() + + cmd = ['./tools/xkvm', + '--disk', '%s,cache=unsafe' % self.disk, + '--disk', '%s,cache=unsafe' % seed, + '--netdev', ','.join(['user', + 'hostfwd=tcp::%s-:22' % self.ssh_port, + 'dnssearch=%s' % CI_DOMAIN]), + '--', '-pidfile', self.pid_file, '-vnc', 'none', + '-m', '2G', '-smp', '2', '-nographic', + '-serial', 'file:' + self.console_file] + subprocess.Popen(cmd, + close_fds=True, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + + while not os.path.exists(self.pid_file): + time.sleep(1) + + with open(self.pid_file, 'r') as pid_f: + self.pid = pid_f.readlines()[0].strip() + + if wait: + self._wait_for_system(wait_for_cloud_init) + + def console_log(self): + if not self.console_file: + return b'' + with open(self.console_file, "rb") as fp: + return fp.read() + +# vi: ts=4 expandtab diff --git a/tests/cloud_tests/platforms/nocloudkvm.py b/tests/cloud_tests/platforms/nocloudkvm/platform.py index 76cd83ad..85933463 100644 --- a/tests/cloud_tests/platforms/nocloudkvm.py +++ b/tests/cloud_tests/platforms/nocloudkvm/platform.py @@ -9,14 +9,14 @@ from simplestreams import mirrors from simplestreams import objectstores from simplestreams import util as s_util +from ..platforms import Platform +from .image import NoCloudKVMImage +from .instance import NoCloudKVMInstance from cloudinit import util as c_util -from tests.cloud_tests.images import nocloudkvm as nocloud_kvm_image -from tests.cloud_tests.instances import nocloudkvm as nocloud_kvm_instance -from tests.cloud_tests.platforms import base from tests.cloud_tests import util -class NoCloudKVMPlatform(base.Platform): +class NoCloudKVMPlatform(Platform): """NoCloud KVM test platform.""" platform_name = 'nocloud-kvm' @@ -62,7 +62,7 @@ class NoCloudKVMPlatform(base.Platform): "Multiple images found in '%s': %s" % (search_d, ' '.join(images))) - image = nocloud_kvm_image.NoCloudKVMImage(self, img_conf, images[0]) + image = NoCloudKVMImage(self, img_conf, images[0]) return image def create_instance(self, properties, config, features, @@ -83,9 +83,7 @@ class NoCloudKVMPlatform(base.Platform): c_util.subp(['qemu-img', 'create', '-f', 'qcow2', '-b', src_img_path, img_path]) - return nocloud_kvm_instance.NoCloudKVMInstance(self, name, img_path, - properties, config, - features, user_data, - meta_data) + return NoCloudKVMInstance(self, name, img_path, properties, config, + features, user_data, meta_data) # vi: ts=4 expandtab diff --git a/tests/cloud_tests/platforms/nocloudkvm/snapshot.py b/tests/cloud_tests/platforms/nocloudkvm/snapshot.py new file mode 100644 index 00000000..0005e1f2 --- /dev/null +++ b/tests/cloud_tests/platforms/nocloudkvm/snapshot.py @@ -0,0 +1,79 @@ +# This file is part of cloud-init. See LICENSE file for license information. + +"""Base NoCloud KVM snapshot.""" +import os +import shutil +import tempfile + +from ..snapshots import Snapshot + + +class NoCloudKVMSnapshot(Snapshot): + """NoCloud KVM image copy backed snapshot.""" + + platform_name = "nocloud-kvm" + + def __init__(self, platform, properties, config, features, image_path): + """Set up snapshot. + + @param platform: platform object + @param properties: image properties + @param config: image config + @param features: supported feature flags + @param image_path: image file to snapshot. + """ + self._workd = tempfile.mkdtemp(prefix='NoCloudKVMSnapshot') + snapshot = os.path.join(self._workd, 'snapshot') + shutil.copyfile(image_path, snapshot) + self._image_path = snapshot + + super(NoCloudKVMSnapshot, 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_instance( + self.properties, self.config, self.features, + self._image_path, 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.""" + shutil.rmtree(self._workd) + super(NoCloudKVMSnapshot, self).destroy() + +# vi: ts=4 expandtab diff --git a/tests/cloud_tests/platforms/base.py b/tests/cloud_tests/platforms/platforms.py index 28975368..28975368 100644 --- a/tests/cloud_tests/platforms/base.py +++ b/tests/cloud_tests/platforms/platforms.py diff --git a/tests/cloud_tests/platforms/snapshots.py b/tests/cloud_tests/platforms/snapshots.py new file mode 100644 index 00000000..94328982 --- /dev/null +++ b/tests/cloud_tests/platforms/snapshots.py @@ -0,0 +1,45 @@ +# This file is part of cloud-init. See LICENSE file for license information. + +"""Base snapshot.""" + + +class Snapshot(object): + """Base class for snapshots.""" + + platform_name = None + + def __init__(self, platform, properties, config, features): + """Set up snapshot. + + @param platform: platform object + @param properties: image properties + @param config: image config + @param features: supported feature flags + """ + self.platform = platform + self.properties = properties + self.config = config + self.features = features + + 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. + + @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 + """ + raise NotImplementedError + + def destroy(self): + """Clean up snapshot data.""" + pass + +# vi: ts=4 expandtab |