diff --git a/lago/cmd.py b/lago/cmd.py index 5285ff29..161fb3f9 100755 --- a/lago/cmd.py +++ b/lago/cmd.py @@ -313,10 +313,20 @@ def do_stop(prefix, vm_names, **kwargs): default='.', help='Dir to place the exported images in', ) +@lago.plugins.cli.cli_plugin_add_argument( + '--init-file-name', + default='LagoInitFile', + help='The name of the exported init file', +) @in_lago_prefix @with_logging -def do_export(prefix, vm_names, standalone, dst_dir, compress, **kwargs): - prefix.export_vms(vm_names, standalone, dst_dir, compress) +def do_export( + prefix, vm_names, standalone, dst_dir, compress, init_file_name, + out_format, **kwargs +): + prefix.export_vms( + vm_names, standalone, dst_dir, compress, init_file_name, out_format + ) @lago.plugins.cli.cli_plugin( diff --git a/lago/plugins/vm.py b/lago/plugins/vm.py index b73af9e3..d3417892 100644 --- a/lago/plugins/vm.py +++ b/lago/plugins/vm.py @@ -27,6 +27,7 @@ create an alternative implementation of the provisioning details for the VM, for example, using a remote libvirt instance or similar. """ +from copy import deepcopy import contextlib import functools import logging @@ -364,6 +365,10 @@ def metadata(self): def disks(self): return self._spec['disks'][:] + @property + def spec(self): + return deepcopy(self._spec) + def name(self): return str(self._spec['name']) diff --git a/lago/prefix.py b/lago/prefix.py index ecc03195..42ecd627 100644 --- a/lago/prefix.py +++ b/lago/prefix.py @@ -961,7 +961,8 @@ def _create_disks( 'dev': disk['dev'], 'format': disk['format'], 'metadata': metadata, - 'type': disk['type'] + 'type': disk['type'], + 'name': disk['name'] }, ) @@ -1009,9 +1010,14 @@ def virt_conf( self.save() rollback.clear() - def export_vms(self, vms_names, standalone, export_dir, compress): - - self.virt_env.export_vms(vms_names, standalone, export_dir, compress) + def export_vms( + self, vms_names, standalone, export_dir, compress, init_file_name, + out_format + ): + self.virt_env.export_vms( + vms_names, standalone, export_dir, compress, init_file_name, + out_format + ) def start(self, vm_names=None): """ diff --git a/lago/utils.py b/lago/utils.py index 7a6c086a..41f5120f 100644 --- a/lago/utils.py +++ b/lago/utils.py @@ -690,6 +690,83 @@ def get_hash(file_path, checksum='sha1'): return sha.hexdigest() +def filter_spec(spec, paths, wildcard='*', separator='/'): + """ + Remove keys from a spec file. + For example, with the following path: domains/*/disks/*/metadata + all the metadata dicts from all domains disks will be removed. + + Args: + spec (dict): spec to remove keys from + paths (list): list of paths to the keys that should be removed + wildcard (str): wildcard character + separator (str): path separator + + Returns: + None + + Raises: + utils.LagoUserException: If a malformed path was detected + """ + + def remove_key(path, spec): + if len(path) == 0: + return + elif len(path) == 1: + key = path.pop() + if not isinstance(spec, collections.Mapping): + raise LagoUserException( + 'You have tried to remove the following key - "{key}".\n' + 'Keys can not be removed from type {spec_type}\n' + 'Please verify that path - "{{path}}" is valid'.format( + key=key, spec_type=type(spec) + ) + ) + if key == wildcard: + spec.clear() + else: + spec.pop(key, None) + else: + current = path[0] + if current == wildcard: + if isinstance(spec, list): + iterator = iter(spec) + elif isinstance(spec, collections.Mapping): + iterator = spec.itervalues() + else: + raise LagoUserException( + 'Glob char {char} should refer only to dict or list, ' + 'not to {spec_type}\n' + 'Please fix path - "{{path}}"'.format( + char=wildcard, spec_type=type(spec) + ) + ) + + for i in iterator: + remove_key(path[1:], i) + else: + try: + remove_key(path[1:], spec[current]) + except KeyError: + raise LagoUserException( + 'Malformed path "{{path}}", key "{key}" ' + 'does not exist'.format(key=current) + ) + except TypeError: + raise LagoUserException( + 'Malformed path "{{path}}", can not get ' + 'by key from type {spec_type}'. + format(spec_type=type(spec)) + ) + + for path in paths: + try: + remove_key(path.split(separator), spec) + except LagoUserException as e: + e.message = e.message.format(path=path) + raise + + class LagoException(Exception): pass diff --git a/lago/virt.py b/lago/virt.py index 27650d6b..621fc6c4 100644 --- a/lago/virt.py +++ b/lago/virt.py @@ -17,6 +17,7 @@ # # Refer to the README and COPYING files for full details of the license # +from copy import deepcopy import functools import hashlib import json @@ -25,6 +26,7 @@ import uuid import time import lxml.etree +import yaml from . import ( brctl, @@ -191,7 +193,10 @@ def virt_path(self, *args): def bootstrap(self): utils.invoke_in_parallel(lambda vm: vm.bootstrap(), self._vms.values()) - def export_vms(self, vms_names, standalone, dst_dir, compress): + def export_vms( + self, vms_names, standalone, dst_dir, compress, init_file_name, + out_format + ): if not vms_names: vms_names = self._vms.keys() @@ -219,6 +224,88 @@ def export_vms(self, vms_names, standalone, dst_dir, compress): for _vm in vms: _vm.export_disks(standalone, dst_dir, compress) + self.generate_init(os.path.join(dst_dir, init_file_name), out_format) + + def generate_init(self, dst, out_format, filters=None): + """ + Generate an init file which represents this env and can + be used with the images created by self.export_vms + + Args: + dst (str): path and name of the new init file + out_format (plugins.output.OutFormatPlugin): + formatter for the output (the default is yaml) + filters (list): list of paths to keys that should be removed from + the init file + Returns: + None + """ + with LogTask('Exporting init file to: {}'.format(dst)): + # Set the default formatter to yaml. The default formatter + # doesn't generate a valid init file, so it's not reasonable + # to use it + if isinstance(out_format, plugins.output.DefaultOutFormatPlugin): + out_format = plugins.output.YAMLOutFormatPlugin() + + if not filters: + filters = [ + 'domains/*/disks/*/metadata', + 'domains/*/metadata/deploy-scripts', 'domains/*/snapshots', + 'domains/*/name', 'nets/*/mapping' + ] + spec = self.get_env_spec(filters) + + for _, domain in spec['domains'].viewitems(): + for disk in domain['disks']: + if disk['type'] == 'template': + disk['template_type'] = 'qcow2' + elif disk['type'] == 'empty': + disk['type'] = 'file' + disk['make_a_copy'] = 'True' + + # Insert the relative path to the exported images + disk['path'] = os.path.join( + '$LAGO_INITFILE_PATH', os.path.basename(disk['path']) + ) + + with open(dst, 'wt') as f: + if isinstance(out_format, plugins.output.YAMLOutFormatPlugin): + # Dump the yaml file without type tags + # TODO: Allow passing parameters to output plugins + f.write(yaml.safe_dump(spec)) + else: + f.write(out_format.format(spec)) + + def get_env_spec(self, filters=None): + """ + Get the spec of the current env. + The spec will hold the info about all the domains and + networks associated with this env. + + Args: + filters (list): list of paths to keys that should be removed from + the init file + Returns: + dict: the spec of the current env + """ + spec = { + 'domains': + { + vm_name: vm_object.spec + for vm_name, vm_object in self._vms.viewitems() + }, + 'nets': + { + net_name: net_object.spec + for net_name, net_object in self._nets.viewitems() + } + } + + if filters: + utils.filter_spec(spec, filters) + + return spec + def start(self, vm_names=None): if not vm_names: log_msg = 'Start Prefix' @@ -459,6 +546,10 @@ def save(self): with open(self._env.virt_path('net-%s' % self.name()), 'w') as f: utils.json_dump(self._spec, f) + @property + def spec(self): + return deepcopy(self._spec) + class NATNetwork(Network): def _libvirt_xml(self): diff --git a/tests/functional/fixtures/snapshot/1host_1disk_list b/tests/functional/fixtures/snapshot/1host_1disk_list index 9556736d..aeb48396 100644 --- a/tests/functional/fixtures/snapshot/1host_1disk_list +++ b/tests/functional/fixtures/snapshot/1host_1disk_list @@ -19,6 +19,7 @@ "size": 1780088832, "version": "v1" }, + "name": "root", "path": "@@PREFIX_PATH@@/images/lago_functional_tests_vm01_root.qcow2", "type": "template" }