
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@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@ovirt.org http://lists.ovirt.org/mailman/listinfo/kimchi-devel