[Kimchi-devel] [PATCH 7/8] Clone virtual machines
Aline Manera
alinefm at linux.vnet.ibm.com
Mon Nov 3 16:47:34 UTC 2014
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()
> +
> + 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})
You do this same verification some lines below.
Couldn't you reorganize the code to only have one occurrence of it? Or
in last case, create a function.
> +
> + 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:
More information about the Kimchi-devel
mailing list