[Kimchi-devel] [PATCH 7/8] Clone virtual machines

Aline Manera alinefm at linux.vnet.ibm.com
Tue Nov 4 11:13:04 UTC 2014


On 11/04/2014 09:07 AM, Aline Manera wrote:
>
> On 11/02/2014 11:05 PM, Crístian Viana wrote:
>> The new command "POST /vms/<vm-name>/clone" will create a new virtual
>> machine based on <vm-name>. The new VM will have the exact same
>> settings, except the name, UUID, MAC address and disk paths; those
>> values will be generated automatically.
>>
>> Signed-off-by: Crístian Viana <vianac at linux.vnet.ibm.com>
>> ---
>>   docs/API.md               |   7 ++
>>   src/kimchi/control/vms.py |   1 +
>>   src/kimchi/i18n.py        |   2 +
>>   src/kimchi/model/vms.py   | 297 
>> +++++++++++++++++++++++++++++++++++++++++++++-
>>   4 files changed, 305 insertions(+), 2 deletions(-)
>>
>> diff --git a/docs/API.md b/docs/API.md
>> index 29ae4e1..d5f8df6 100644
>> --- a/docs/API.md
>> +++ b/docs/API.md
>> @@ -133,6 +133,13 @@ the following general conventions:
>>            risk of data loss caused by reset without the guest OS 
>> shutdown.
>>   * connect: Prepare the connection for spice or vnc
>>   +* clone: Create a new VM identical to this VM. The new VM's name, 
>> UUID and
>> +         network MAC addresses will be generated automatically. Each 
>> existing
>> +         disks will be copied to a new volume in the same storage 
>> pool. If
>> +         there is no available space on that storage pool to hold 
>> the new
>> +         volume, it will be created on the pool 'default'. This 
>> action returns
>> +         a Task.
>> +
>>   ### Sub-resource: Virtual Machine Screenshot
>>     **URI:** /vms/*:name*/screenshot
>> diff --git a/src/kimchi/control/vms.py b/src/kimchi/control/vms.py
>> index 88d8a81..a1589ef 100644
>> --- a/src/kimchi/control/vms.py
>> +++ b/src/kimchi/control/vms.py
>> @@ -46,6 +46,7 @@ class VM(Resource):
>>           self.shutdown = self.generate_action_handler('shutdown')
>>           self.reset = self.generate_action_handler('reset')
>>           self.connect = self.generate_action_handler('connect')
>> +        self.clone = self.generate_action_handler_task('clone')
>>         @property
>>       def data(self):
>> diff --git a/src/kimchi/i18n.py b/src/kimchi/i18n.py
>> index 712e5a6..304c870 100644
>> --- a/src/kimchi/i18n.py
>> +++ b/src/kimchi/i18n.py
>> @@ -102,6 +102,8 @@ messages = {
>>       "KCHVM0030E": _("Unable to get access metadata of virtual 
>> machine %(name)s. Details: %(err)s"),
>>       "KCHVM0031E": _("The guest console password must be a string."),
>>       "KCHVM0032E": _("The life time for the guest console password 
>> must be a number."),
>> +    "KCHVM0033E": _("Virtual machine '%(name)s' must be stopped 
>> before cloning it."),
>> +    "KCHVM0034E": _("Insufficient disk space to clone virtual 
>> machine '%(name)s'"),
>>         "KCHVMHDEV0001E": _("VM %(vmid)s does not contain directly 
>> assigned host device %(dev_name)s."),
>>       "KCHVMHDEV0002E": _("The host device %(dev_name)s is not 
>> allowed to directly assign to VM."),
>> diff --git a/src/kimchi/model/vms.py b/src/kimchi/model/vms.py
>> index f2e4ae3..17fe2f4 100644
>> --- a/src/kimchi/model/vms.py
>> +++ b/src/kimchi/model/vms.py
>> @@ -22,6 +22,7 @@ import lxml.etree as ET
>>   from lxml import etree, objectify
>>   import os
>>   import random
>> +import re
>>   import string
>>   import time
>>   import uuid
>> @@ -30,17 +31,20 @@ from xml.etree import ElementTree
>>   import libvirt
>>   from cherrypy.process.plugins import BackgroundTask
>>   -from kimchi import vnc
>> +from kimchi import model, vnc
>>   from kimchi.config import READONLY_POOL_TYPE
>>   from kimchi.exception import InvalidOperation, InvalidParameter
>>   from kimchi.exception import NotFoundError, OperationFailed
>>   from kimchi.model.config import CapabilitiesModel
>> +from kimchi.model.tasks import TaskModel
>>   from kimchi.model.templates import TemplateModel
>>   from kimchi.model.utils import get_vm_name
>>   from kimchi.model.utils import get_metadata_node
>>   from kimchi.model.utils import set_metadata_node
>> +from kimchi.rollbackcontext import RollbackContext
>>   from kimchi.screenshot import VMScreenshot
>> -from kimchi.utils import import_class, kimchi_log, run_setfacl_set_attr
>> +from kimchi.utils import add_task, import_class, kimchi_log
>> +from kimchi.utils import run_setfacl_set_attr
>>   from kimchi.utils import template_name_from_uri
>>   from kimchi.xmlutils.utils import xpath_get_text, xml_item_update
>>   @@ -63,6 +67,15 @@ VM_LIVE_UPDATE_PARAMS = {}
>>   stats = {}
>>     +XPATH_DOMAIN_DISK = 
>> "/domain/devices/disk[@device='disk']/source/@file"
>> +XPATH_DOMAIN_DISK_BY_FILE = 
>> "./devices/disk[@device='disk']/source[@file='%s']"
>> +XPATH_DOMAIN_NAME = '/domain/name'
>> +XPATH_DOMAIN_MAC = 
>> "/domain/devices/interface[@type='network']/mac/@address"
>> +XPATH_DOMAIN_MAC_BY_ADDRESS = "./devices/interface[@type='network']/"\
>> +                              "mac[@address='%s']"
>> +XPATH_DOMAIN_UUID = '/domain/uuid'
>> +
>> +
>>   class VMsModel(object):
>>       def __init__(self, **kargs):
>>           self.conn = kargs['conn']
>> @@ -251,6 +264,11 @@ class VMModel(object):
>>           self.vmscreenshot = VMScreenshotModel(**kargs)
>>           self.users = 
>> import_class('kimchi.model.host.UsersModel')(**kargs)
>>           self.groups = 
>> import_class('kimchi.model.host.GroupsModel')(**kargs)
>> +        self.vms = VMsModel(**kargs)
>> +        self.task = TaskModel(**kargs)
>> +        self.storagepool = model.storagepools.StoragePoolModel(**kargs)
>> +        self.storagevolume = 
>> model.storagevolumes.StorageVolumeModel(**kargs)
>> +        self.storagevolumes = 
>> model.storagevolumes.StorageVolumesModel(**kargs)
>>         def update(self, name, params):
>>           dom = self.get_vm(name, self.conn)
>> @@ -258,6 +276,281 @@ class VMModel(object):
>>           self._live_vm_update(dom, params)
>>           return dom.name().decode('utf-8')
>>   +    def clone(self, name):
>> +        """Clone a virtual machine based on an existing one.
>> +
>> +        The new virtual machine will have the exact same 
>> configuration as the
>> +        original VM, except for the name, UUID, MAC addresses and 
>> disks. The
>> +        name will have the form "<name>-clone-<number>", with 
>> <number> starting
>> +        at 1; the UUID will be generated randomly; the MAC addresses 
>> will be
>> +        generated randomly with no conflicts within the original and 
>> the new
>> +        VM; and the disks will be new volumes [mostly] on the same 
>> storage
>> +        pool, with the same content as the original disks. The 
>> storage pool
>> +        'default' will always be used when cloning SCSI and iSCSI 
>> disks and
>> +        when the original storage pool cannot hold the new volume.
>> +
>> +        An exception will be raised if the virtual machine <name> is 
>> not
>> +        shutoff, if there is no available space to copy a new volume 
>> to the
>> +        storage pool 'default' (when there was also no space to copy 
>> it to the
>> +        original storage pool) and if one of the virtual machine's 
>> disks belong
>> +        to a storage pool not supported by Kimchi.
>> +
>> +        Parameters:
>> +        name -- The name of the existing virtual machine to be cloned.
>> +
>> +        Return:
>> +        A Task running the clone operation.
>> +        """
>> +        name = name.decode('utf-8')
>> +
>> +        # VM must be shutoff in order to clone it
>> +        info = self.lookup(name)
>> +        if info['state'] != u'shutoff':
>> +            raise InvalidParameter('KCHVM0033E', {'name': name})
>> +
>> +        # this name will be used as the Task's 'target_uri' so it 
>> needs to be
>> +        # defined now.
>> +        new_name = self._clone_get_next_name(name)
>> +
>> +        # create a task with the actual clone function
>> +        taskid = add_task(u'/vms/%s' % new_name, self._clone_task,
>> +                          self.objstore,
>> +                          {'name': name, 'new_name': new_name})
>> +
>> +        return self.task.lookup(taskid)
>> +
>> +    def _clone_task(self, cb, params):
>> +        """Asynchronous function which performs the clone operation.
>> +
>> +        Parameters:
>> +        cb -- A callback function to signal the Task's progress.
>> +        params -- A dict with the following values:
>> +            "name": the name of the original VM.
>> +            "new_name": the name of the new VM.
>> +        """
>> +        name = params['name']
>> +        new_name = params['new_name']
>> +        vir_conn = self.conn.get()
>> +
>> +        # fetch base XML
>> +        cb('reading source VM XML')
>> +        vir_dom = vir_conn.lookupByName(name)
>> +        xml = 
>> vir_dom.XMLDesc(libvirt.VIR_DOMAIN_XML_SECURE).decode('utf-8')
>> +
>> +        # update name
>> +        cb('updating VM name')
>> +        xml = xml_item_update(xml, './name', new_name)
>> +
>> +        # update UUID
>> +        cb('updating VM UUID')
>> +        new_uuid = unicode(uuid.uuid4())
>> +        xml = xml_item_update(xml, './uuid', new_uuid)
>> +
>> +        # update MAC addresses
>> +        cb('updating VM MAC addresses')
>> +        xml = self._clone_update_mac_addresses(xml)
>> +
>> +        with RollbackContext() as rollback:
>> +            # copy disks
>> +            cb('copying VM disks')
>> +            xml = self._clone_update_disks(xml, rollback)
>> +
>> +            # update objstore entry
>> +            cb('updating object store')
>> +            self._clone_update_objstore(vir_dom, new_uuid, rollback)
>> +
>> +            # create new guest
>> +            cb('defining new VM')
>> +            vir_conn.defineXML(xml)
>> +
>> +            rollback.commitAll()
>> +
>> +        cb('OK', True)
>> +
>> +    def _clone_get_next_name(self, basename):
>> +        """Find the next available name for a clone VM.
>> +
>> +        If any VM named "<basename>-clone-<number>" is found, use
>> +        the maximum "number" + 1; else, use 1.
>> +
>> +        Argument:
>> +        basename -- the name of the original VM.
>> +
>> +        Return:
>> +        A UTF-8 string in the format "<basename>-clone-<number>".
>> +        """
>> +        re_group_num = 'num'
>> +
>> +        max_num = 0
>> +        re_cloned_vm = re.compile(u'%s-clone-(?P<%s>\d+)' %
>> +                                  (basename, re_group_num))
>> +
>> +        vm_names = self.vms.get_list()
>> +
>> +        for v in vm_names:
>> +            match = re_cloned_vm.match(v)
>> +            if match is not None:
>> +                max_num = max(max_num, int(match.group(re_group_num)))
>> +
>> +        # increments the maximum "clone number" found
>> +        return u'%s-clone-%d' % (basename, max_num + 1)
>> +
>> +    @staticmethod
>> +    def _clone_update_mac_addresses(xml):
>> +        """Update the MAC addresses with new values in the XML 
>> descriptor of a
>> +        cloning domain.
>> +
>> +        The new MAC addresses will be generated randomly, and their 
>> values are
>> +        guaranteed to be distinct from the ones in the original VM.
>> +
>> +        Arguments:
>> +        xml -- The XML descriptor of the original domain.
>> +
>> +        Return:
>> +        The XML descriptor <xml> with the new MAC addresses instead 
>> of the
>> +        old ones.
>> +        """
>> +        old_macs = xpath_get_text(xml, XPATH_DOMAIN_MAC)
>> +        new_macs = []
>> +
>> +        for mac in old_macs:
>> +            while True:
>> +                new_mac = model.vmifaces.VMIfacesModel.random_mac()
>> +                # make sure the new MAC doesn't conflict with the 
>> original VM
>> +                # and with the new values on the new VM.
>> +                if new_mac not in (old_macs + new_macs):
>> +                    new_macs.append(new_mac)
>> +                    break
>> +
>> +            xml = xml_item_update(xml, XPATH_DOMAIN_MAC_BY_ADDRESS % 
>> mac,
>> +                                  new_mac, 'address')
>> +
>> +        return xml
>> +
>> +    def _clone_update_disks(self, xml, rollback):
>> +        """Clone disks from a virtual machine. The disks are copied 
>> as new
>> +        volumes and the new VM's XML is updated accordingly.
>> +
>> +        Arguments:
>> +        xml -- The XML descriptor of the original VM + new values for
>> +            "/domain/name" and "/domain/uuid".
>> +        rollback -- A rollback context so the new volumes can be 
>> removed if an
>> +            error occurs during the cloning operation.
>> +
>> +        Return:
>> +        The XML descriptor <xml> with the new disk paths instead of the
>> +        old ones.
>> +        """
>> +        # the UUID will be used to create the disk paths
>> +        uuid = xpath_get_text(xml, XPATH_DOMAIN_UUID)[0]
>> +        all_paths = xpath_get_text(xml, XPATH_DOMAIN_DISK)
>> +
>> +        vir_conn = self.conn.get()
>> +
>
>> +        for i, path in enumerate(all_paths):
>> +            vir_orig_vol = vir_conn.storageVolLookupByPath(path)
>> +            vir_pool = vir_orig_vol.storagePoolLookupByVolume()
>> +
>
> I've just thought about the case the disk is not in a pool.
> For example, we allow user create a Template (and then a VM) using an 
> Image file. That image file is not necessarily on a pool. ie, it is a 
> volume from libvirt perspective.

it is *NOT* a volume from libvirt perspective

> So in that case the call vir_conn.storageVolLookupByPath(path) will 
> raise an exception and stop the clone operation.
> We need to have this scenario in mind and work with it properly.
>
>
>> +            orig_pool_name = vir_pool.name().decode('utf-8')
>> +            orig_vol_name = vir_orig_vol.name().decode('utf-8')
>> +
>> +            orig_pool = self.storagepool.lookup(orig_pool_name)
>> +            orig_vol = self.storagevolume.lookup(orig_pool_name, 
>> orig_vol_name)
>> +
>> +            new_pool_name = orig_pool_name
>> +            new_pool = orig_pool
>> +
>> +            if orig_pool['type'] in ['dir', 'netfs', 'logical']:
>> +                # if a volume in a pool 'dir', 'netfs' or 'logical' 
>> cannot hold
>> +                # a new volume with the same size, the pool 
>> 'default' should
>> +                # be used
>> +                if orig_vol['capacity'] > orig_pool['available']:
>> +                    kimchi_log.warning('storage pool \'%s\' doesn\'t 
>> have '
>> +                                       'enough free space to store 
>> image '
>> +                                       '\'%s\'; falling back to 
>> \'default\'',
>> +                                       orig_pool_name, path)
>> +                    new_pool_name = u'default'
>> +                    new_pool = self.storagepool.lookup(u'default')
>> +
>> +                    # ...and if even the pool 'default' cannot hold 
>> a new
>> +                    # volume, raise an exception
>> +                    if orig_vol['capacity'] > new_pool['available']:
>> +                        domain_name = xpath_get_text(xml, 
>> XPATH_DOMAIN_NAME)[0]
>> +                        raise InvalidOperation('KCHVM0034E',
>> +                                               {'name': domain_name})
>> +
>> +            elif orig_pool['type'] in ['scsi', 'iscsi']:
>> +                # SCSI and iSCSI always fall back to the storage 
>> pool 'default'
>> +                kimchi_log.warning('cannot create new volume for 
>> clone in '
>> +                                   'storage pool \'%s\'; falling 
>> back to '
>> +                                   '\'default\'', orig_pool_name)
>> +                new_pool_name = u'default'
>> +                new_pool = self.storagepool.lookup(u'default')
>> +
>> +                # if the pool 'default' cannot hold a new volume, raise
>> +                # an exception
>> +                if orig_vol['capacity'] > new_pool['available']:
>> +                    domain_name = xpath_get_text(xml, 
>> XPATH_DOMAIN_NAME)[0]
>> +                    raise InvalidOperation('KCHVM0034E', {'name': 
>> domain_name})
>> +
>> +            else:
>> +                # unexpected storage pool type
>> +                raise InvalidOperation('KCHPOOL0014E',
>> +                                       {'type': orig_pool['type']})
>> +
>> +            # new volume name: <UUID>-<loop-index>.<original extension>
>> +            # e.g. 1234-5678-9012-3456-0.img
>> +            ext = os.path.splitext(path)[1]
>> +            new_vol_name = u'%s-%d%s' % (uuid, i, ext)
>> +            new_vol_params = {'name': new_vol_name, 'volume_path': 
>> path}
>> +            task = self.storagevolumes.create(new_pool_name, 
>> new_vol_params)
>> +            self.task.wait(task['id'], 3600)  # 1 h
>> +
>> +            # get the new volume path and update the XML descriptor
>> +            new_vol = self.storagevolume.lookup(new_pool_name, 
>> new_vol_name)
>> +            xml = xml_item_update(xml, XPATH_DOMAIN_DISK_BY_FILE % 
>> path,
>> +                                  new_vol['path'], 'file')
>> +
>> +            # remove the new volume should an error occur later
>> +            rollback.prependDefer(self.storagevolume.delete, 
>> new_pool_name,
>> +                                  new_vol_name)
>> +
>> +        return xml
>> +
>> +    def _clone_update_objstore(self, dom, new_uuid, rollback):
>> +        """Update Kimchi's object store with the cloning VM.
>> +
>> +        Arguments:
>> +        dom -- The native domain object of the original VM.
>> +        new_uuid -- The UUID of the new, clonning VM.
>> +        rollback -- A rollback context so the object store entry can 
>> be removed
>> +            if an error occurs during the cloning operation.
>> +        """
>> +        xml = 
>> dom.XMLDesc(libvirt.VIR_DOMAIN_XML_SECURE).decode('utf-8')
>> +        old_uuid = xpath_get_text(xml, XPATH_DOMAIN_UUID)[0]
>> +
>> +        with self.objstore as session:
>> +            try:
>> +                vm = session.get('vm', old_uuid)
>> +                icon = vm['icon']
>> +                session.store('vm', new_uuid, {'icon': icon})
>> +            except NotFoundError:
>> +                # if we cannot find an object store entry for the 
>> original VM,
>> +                # don't store one with an empty value.
>> +                pass
>> +            else:
>> +                # we need to define a custom function to prepend to the
>> +                # rollback context because the object store session 
>> needs to be
>> +                # opened and closed correctly (i.e. "prependDefer" only
>> +                # accepts one command at a time but we need more 
>> than one to
>> +                # handle an object store).
>> +                def _rollback_objstore():
>> +                    with self.objstore as session_rb:
>> +                        session_rb.delete('vm', new_uuid, 
>> ignore_missing=True)
>> +
>> +                # remove the new object store entry should an error 
>> occur later
>> +                rollback.prependDefer(_rollback_objstore)
>> +
>>       def _build_access_elem(self, users, groups):
>>           access = E.access()
>>           for user in users:
>
> _______________________________________________
> Kimchi-devel mailing list
> Kimchi-devel at ovirt.org
> http://lists.ovirt.org/mailman/listinfo/kimchi-devel

-------------- next part --------------
An HTML attachment was scrubbed...
URL: <http://lists.ovirt.org/pipermail/kimchi-devel/attachments/20141104/afd2fd69/attachment.html>


More information about the Kimchi-devel mailing list