<html>
<head>
<meta content="text/html; charset=utf-8" http-equiv="Content-Type">
</head>
<body bgcolor="#FFFFFF" text="#000000">
<br>
<div class="moz-cite-prefix">On 11/04/2014 09:07 AM, Aline Manera
wrote:<br>
</div>
<blockquote cite="mid:5458B38B.8010802@linux.vnet.ibm.com"
type="cite">
<br>
On 11/02/2014 11:05 PM, Crístian Viana wrote:
<br>
<blockquote type="cite">The new command "POST
/vms/<vm-name>/clone" will create a new virtual
<br>
machine based on <vm-name>. The new VM will have the exact
same
<br>
settings, except the name, UUID, MAC address and disk paths;
those
<br>
values will be generated automatically.
<br>
<br>
Signed-off-by: Crístian Viana <a class="moz-txt-link-rfc2396E" href="mailto:vianac@linux.vnet.ibm.com"><vianac@linux.vnet.ibm.com></a>
<br>
---
<br>
docs/API.md | 7 ++
<br>
src/kimchi/control/vms.py | 1 +
<br>
src/kimchi/i18n.py | 2 +
<br>
src/kimchi/model/vms.py | 297
+++++++++++++++++++++++++++++++++++++++++++++-
<br>
4 files changed, 305 insertions(+), 2 deletions(-)
<br>
<br>
diff --git a/docs/API.md b/docs/API.md
<br>
index 29ae4e1..d5f8df6 100644
<br>
--- a/docs/API.md
<br>
+++ b/docs/API.md
<br>
@@ -133,6 +133,13 @@ the following general conventions:
<br>
risk of data loss caused by reset without the guest
OS shutdown.
<br>
* connect: Prepare the connection for spice or vnc
<br>
+* clone: Create a new VM identical to this VM. The new VM's
name, UUID and
<br>
+ network MAC addresses will be generated automatically.
Each existing
<br>
+ disks will be copied to a new volume in the same
storage pool. If
<br>
+ there is no available space on that storage pool to
hold the new
<br>
+ volume, it will be created on the pool 'default'. This
action returns
<br>
+ a Task.
<br>
+
<br>
### Sub-resource: Virtual Machine Screenshot
<br>
**URI:** /vms/*:name*/screenshot
<br>
diff --git a/src/kimchi/control/vms.py
b/src/kimchi/control/vms.py
<br>
index 88d8a81..a1589ef 100644
<br>
--- a/src/kimchi/control/vms.py
<br>
+++ b/src/kimchi/control/vms.py
<br>
@@ -46,6 +46,7 @@ class VM(Resource):
<br>
self.shutdown =
self.generate_action_handler('shutdown')
<br>
self.reset = self.generate_action_handler('reset')
<br>
self.connect = self.generate_action_handler('connect')
<br>
+ self.clone = self.generate_action_handler_task('clone')
<br>
@property
<br>
def data(self):
<br>
diff --git a/src/kimchi/i18n.py b/src/kimchi/i18n.py
<br>
index 712e5a6..304c870 100644
<br>
--- a/src/kimchi/i18n.py
<br>
+++ b/src/kimchi/i18n.py
<br>
@@ -102,6 +102,8 @@ messages = {
<br>
"KCHVM0030E": _("Unable to get access metadata of virtual
machine %(name)s. Details: %(err)s"),
<br>
"KCHVM0031E": _("The guest console password must be a
string."),
<br>
"KCHVM0032E": _("The life time for the guest console
password must be a number."),
<br>
+ "KCHVM0033E": _("Virtual machine '%(name)s' must be stopped
before cloning it."),
<br>
+ "KCHVM0034E": _("Insufficient disk space to clone virtual
machine '%(name)s'"),
<br>
"KCHVMHDEV0001E": _("VM %(vmid)s does not contain
directly assigned host device %(dev_name)s."),
<br>
"KCHVMHDEV0002E": _("The host device %(dev_name)s is not
allowed to directly assign to VM."),
<br>
diff --git a/src/kimchi/model/vms.py b/src/kimchi/model/vms.py
<br>
index f2e4ae3..17fe2f4 100644
<br>
--- a/src/kimchi/model/vms.py
<br>
+++ b/src/kimchi/model/vms.py
<br>
@@ -22,6 +22,7 @@ import lxml.etree as ET
<br>
from lxml import etree, objectify
<br>
import os
<br>
import random
<br>
+import re
<br>
import string
<br>
import time
<br>
import uuid
<br>
@@ -30,17 +31,20 @@ from xml.etree import ElementTree
<br>
import libvirt
<br>
from cherrypy.process.plugins import BackgroundTask
<br>
-from kimchi import vnc
<br>
+from kimchi import model, vnc
<br>
from kimchi.config import READONLY_POOL_TYPE
<br>
from kimchi.exception import InvalidOperation,
InvalidParameter
<br>
from kimchi.exception import NotFoundError, OperationFailed
<br>
from kimchi.model.config import CapabilitiesModel
<br>
+from kimchi.model.tasks import TaskModel
<br>
from kimchi.model.templates import TemplateModel
<br>
from kimchi.model.utils import get_vm_name
<br>
from kimchi.model.utils import get_metadata_node
<br>
from kimchi.model.utils import set_metadata_node
<br>
+from kimchi.rollbackcontext import RollbackContext
<br>
from kimchi.screenshot import VMScreenshot
<br>
-from kimchi.utils import import_class, kimchi_log,
run_setfacl_set_attr
<br>
+from kimchi.utils import add_task, import_class, kimchi_log
<br>
+from kimchi.utils import run_setfacl_set_attr
<br>
from kimchi.utils import template_name_from_uri
<br>
from kimchi.xmlutils.utils import xpath_get_text,
xml_item_update
<br>
@@ -63,6 +67,15 @@ VM_LIVE_UPDATE_PARAMS = {}
<br>
stats = {}
<br>
+XPATH_DOMAIN_DISK =
"/domain/devices/disk[@device='disk']/source/@file"
<br>
+XPATH_DOMAIN_DISK_BY_FILE =
"./devices/disk[@device='disk']/source[@file='%s']"
<br>
+XPATH_DOMAIN_NAME = '/domain/name'
<br>
+XPATH_DOMAIN_MAC =
"/domain/devices/interface[@type='network']/mac/@address"
<br>
+XPATH_DOMAIN_MAC_BY_ADDRESS =
"./devices/interface[@type='network']/"\
<br>
+ "mac[@address='%s']"
<br>
+XPATH_DOMAIN_UUID = '/domain/uuid'
<br>
+
<br>
+
<br>
class VMsModel(object):
<br>
def __init__(self, **kargs):
<br>
self.conn = kargs['conn']
<br>
@@ -251,6 +264,11 @@ class VMModel(object):
<br>
self.vmscreenshot = VMScreenshotModel(**kargs)
<br>
self.users =
import_class('kimchi.model.host.UsersModel')(**kargs)
<br>
self.groups =
import_class('kimchi.model.host.GroupsModel')(**kargs)
<br>
+ self.vms = VMsModel(**kargs)
<br>
+ self.task = TaskModel(**kargs)
<br>
+ self.storagepool =
model.storagepools.StoragePoolModel(**kargs)
<br>
+ self.storagevolume =
model.storagevolumes.StorageVolumeModel(**kargs)
<br>
+ self.storagevolumes =
model.storagevolumes.StorageVolumesModel(**kargs)
<br>
def update(self, name, params):
<br>
dom = self.get_vm(name, self.conn)
<br>
@@ -258,6 +276,281 @@ class VMModel(object):
<br>
self._live_vm_update(dom, params)
<br>
return dom.name().decode('utf-8')
<br>
+ def clone(self, name):
<br>
+ """Clone a virtual machine based on an existing one.
<br>
+
<br>
+ The new virtual machine will have the exact same
configuration as the
<br>
+ original VM, except for the name, UUID, MAC addresses
and disks. The
<br>
+ name will have the form
"<name>-clone-<number>", with <number>
starting
<br>
+ at 1; the UUID will be generated randomly; the MAC
addresses will be
<br>
+ generated randomly with no conflicts within the
original and the new
<br>
+ VM; and the disks will be new volumes [mostly] on the
same storage
<br>
+ pool, with the same content as the original disks. The
storage pool
<br>
+ 'default' will always be used when cloning SCSI and
iSCSI disks and
<br>
+ when the original storage pool cannot hold the new
volume.
<br>
+
<br>
+ An exception will be raised if the virtual machine
<name> is not
<br>
+ shutoff, if there is no available space to copy a new
volume to the
<br>
+ storage pool 'default' (when there was also no space to
copy it to the
<br>
+ original storage pool) and if one of the virtual
machine's disks belong
<br>
+ to a storage pool not supported by Kimchi.
<br>
+
<br>
+ Parameters:
<br>
+ name -- The name of the existing virtual machine to be
cloned.
<br>
+
<br>
+ Return:
<br>
+ A Task running the clone operation.
<br>
+ """
<br>
+ name = name.decode('utf-8')
<br>
+
<br>
+ # VM must be shutoff in order to clone it
<br>
+ info = self.lookup(name)
<br>
+ if info['state'] != u'shutoff':
<br>
+ raise InvalidParameter('KCHVM0033E', {'name':
name})
<br>
+
<br>
+ # this name will be used as the Task's 'target_uri' so
it needs to be
<br>
+ # defined now.
<br>
+ new_name = self._clone_get_next_name(name)
<br>
+
<br>
+ # create a task with the actual clone function
<br>
+ taskid = add_task(u'/vms/%s' % new_name,
self._clone_task,
<br>
+ self.objstore,
<br>
+ {'name': name, 'new_name': new_name})
<br>
+
<br>
+ return self.task.lookup(taskid)
<br>
+
<br>
+ def _clone_task(self, cb, params):
<br>
+ """Asynchronous function which performs the clone
operation.
<br>
+
<br>
+ Parameters:
<br>
+ cb -- A callback function to signal the Task's
progress.
<br>
+ params -- A dict with the following values:
<br>
+ "name": the name of the original VM.
<br>
+ "new_name": the name of the new VM.
<br>
+ """
<br>
+ name = params['name']
<br>
+ new_name = params['new_name']
<br>
+ vir_conn = self.conn.get()
<br>
+
<br>
+ # fetch base XML
<br>
+ cb('reading source VM XML')
<br>
+ vir_dom = vir_conn.lookupByName(name)
<br>
+ xml =
vir_dom.XMLDesc(libvirt.VIR_DOMAIN_XML_SECURE).decode('utf-8')
<br>
+
<br>
+ # update name
<br>
+ cb('updating VM name')
<br>
+ xml = xml_item_update(xml, './name', new_name)
<br>
+
<br>
+ # update UUID
<br>
+ cb('updating VM UUID')
<br>
+ new_uuid = unicode(uuid.uuid4())
<br>
+ xml = xml_item_update(xml, './uuid', new_uuid)
<br>
+
<br>
+ # update MAC addresses
<br>
+ cb('updating VM MAC addresses')
<br>
+ xml = self._clone_update_mac_addresses(xml)
<br>
+
<br>
+ with RollbackContext() as rollback:
<br>
+ # copy disks
<br>
+ cb('copying VM disks')
<br>
+ xml = self._clone_update_disks(xml, rollback)
<br>
+
<br>
+ # update objstore entry
<br>
+ cb('updating object store')
<br>
+ self._clone_update_objstore(vir_dom, new_uuid,
rollback)
<br>
+
<br>
+ # create new guest
<br>
+ cb('defining new VM')
<br>
+ vir_conn.defineXML(xml)
<br>
+
<br>
+ rollback.commitAll()
<br>
+
<br>
+ cb('OK', True)
<br>
+
<br>
+ def _clone_get_next_name(self, basename):
<br>
+ """Find the next available name for a clone VM.
<br>
+
<br>
+ If any VM named "<basename>-clone-<number>"
is found, use
<br>
+ the maximum "number" + 1; else, use 1.
<br>
+
<br>
+ Argument:
<br>
+ basename -- the name of the original VM.
<br>
+
<br>
+ Return:
<br>
+ A UTF-8 string in the format
"<basename>-clone-<number>".
<br>
+ """
<br>
+ re_group_num = 'num'
<br>
+
<br>
+ max_num = 0
<br>
+ re_cloned_vm = re.compile(u'%s-clone-(?P<%s>\d+)'
%
<br>
+ (basename, re_group_num))
<br>
+
<br>
+ vm_names = self.vms.get_list()
<br>
+
<br>
+ for v in vm_names:
<br>
+ match = re_cloned_vm.match(v)
<br>
+ if match is not None:
<br>
+ max_num = max(max_num,
int(match.group(re_group_num)))
<br>
+
<br>
+ # increments the maximum "clone number" found
<br>
+ return u'%s-clone-%d' % (basename, max_num + 1)
<br>
+
<br>
+ @staticmethod
<br>
+ def _clone_update_mac_addresses(xml):
<br>
+ """Update the MAC addresses with new values in the XML
descriptor of a
<br>
+ cloning domain.
<br>
+
<br>
+ The new MAC addresses will be generated randomly, and
their values are
<br>
+ guaranteed to be distinct from the ones in the original
VM.
<br>
+
<br>
+ Arguments:
<br>
+ xml -- The XML descriptor of the original domain.
<br>
+
<br>
+ Return:
<br>
+ The XML descriptor <xml> with the new MAC
addresses instead of the
<br>
+ old ones.
<br>
+ """
<br>
+ old_macs = xpath_get_text(xml, XPATH_DOMAIN_MAC)
<br>
+ new_macs = []
<br>
+
<br>
+ for mac in old_macs:
<br>
+ while True:
<br>
+ new_mac =
model.vmifaces.VMIfacesModel.random_mac()
<br>
+ # make sure the new MAC doesn't conflict with
the original VM
<br>
+ # and with the new values on the new VM.
<br>
+ if new_mac not in (old_macs + new_macs):
<br>
+ new_macs.append(new_mac)
<br>
+ break
<br>
+
<br>
+ xml = xml_item_update(xml,
XPATH_DOMAIN_MAC_BY_ADDRESS % mac,
<br>
+ new_mac, 'address')
<br>
+
<br>
+ return xml
<br>
+
<br>
+ def _clone_update_disks(self, xml, rollback):
<br>
+ """Clone disks from a virtual machine. The disks are
copied as new
<br>
+ volumes and the new VM's XML is updated accordingly.
<br>
+
<br>
+ Arguments:
<br>
+ xml -- The XML descriptor of the original VM + new
values for
<br>
+ "/domain/name" and "/domain/uuid".
<br>
+ rollback -- A rollback context so the new volumes can
be removed if an
<br>
+ error occurs during the cloning operation.
<br>
+
<br>
+ Return:
<br>
+ The XML descriptor <xml> with the new disk paths
instead of the
<br>
+ old ones.
<br>
+ """
<br>
+ # the UUID will be used to create the disk paths
<br>
+ uuid = xpath_get_text(xml, XPATH_DOMAIN_UUID)[0]
<br>
+ all_paths = xpath_get_text(xml, XPATH_DOMAIN_DISK)
<br>
+
<br>
+ vir_conn = self.conn.get()
<br>
+
<br>
</blockquote>
<br>
<blockquote type="cite">+ for i, path in
enumerate(all_paths):
<br>
+ vir_orig_vol =
vir_conn.storageVolLookupByPath(path)
<br>
+ vir_pool = vir_orig_vol.storagePoolLookupByVolume()
<br>
+
<br>
</blockquote>
<br>
I've just thought about the case the disk is not in a pool.
<br>
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.
<br>
</blockquote>
<br>
it is <b>NOT</b> a volume from libvirt perspective<br>
<br>
<blockquote cite="mid:5458B38B.8010802@linux.vnet.ibm.com"
type="cite">So in that case the call
vir_conn.storageVolLookupByPath(path) will raise an exception and
stop the clone operation.
<br>
We need to have this scenario in mind and work with it properly.
<br>
<br>
<br>
<blockquote type="cite">+ orig_pool_name =
vir_pool.name().decode('utf-8')
<br>
+ orig_vol_name = vir_orig_vol.name().decode('utf-8')
<br>
+
<br>
+ orig_pool = self.storagepool.lookup(orig_pool_name)
<br>
+ orig_vol =
self.storagevolume.lookup(orig_pool_name, orig_vol_name)
<br>
+
<br>
+ new_pool_name = orig_pool_name
<br>
+ new_pool = orig_pool
<br>
+
<br>
+ if orig_pool['type'] in ['dir', 'netfs',
'logical']:
<br>
+ # if a volume in a pool 'dir', 'netfs' or
'logical' cannot hold
<br>
+ # a new volume with the same size, the pool
'default' should
<br>
+ # be used
<br>
+ if orig_vol['capacity'] >
orig_pool['available']:
<br>
+ kimchi_log.warning('storage pool \'%s\'
doesn\'t have '
<br>
+ 'enough free space to
store image '
<br>
+ '\'%s\'; falling back to
\'default\'',
<br>
+ orig_pool_name, path)
<br>
+ new_pool_name = u'default'
<br>
+ new_pool =
self.storagepool.lookup(u'default')
<br>
+
<br>
+ # ...and if even the pool 'default' cannot
hold a new
<br>
+ # volume, raise an exception
<br>
+ if orig_vol['capacity'] >
new_pool['available']:
<br>
+ domain_name = xpath_get_text(xml,
XPATH_DOMAIN_NAME)[0]
<br>
+ raise InvalidOperation('KCHVM0034E',
<br>
+ {'name':
domain_name})
<br>
+
<br>
+ elif orig_pool['type'] in ['scsi', 'iscsi']:
<br>
+ # SCSI and iSCSI always fall back to the
storage pool 'default'
<br>
+ kimchi_log.warning('cannot create new volume
for clone in '
<br>
+ 'storage pool \'%s\';
falling back to '
<br>
+ '\'default\'',
orig_pool_name)
<br>
+ new_pool_name = u'default'
<br>
+ new_pool = self.storagepool.lookup(u'default')
<br>
+
<br>
+ # if the pool 'default' cannot hold a new
volume, raise
<br>
+ # an exception
<br>
+ if orig_vol['capacity'] >
new_pool['available']:
<br>
+ domain_name = xpath_get_text(xml,
XPATH_DOMAIN_NAME)[0]
<br>
+ raise InvalidOperation('KCHVM0034E',
{'name': domain_name})
<br>
+
<br>
+ else:
<br>
+ # unexpected storage pool type
<br>
+ raise InvalidOperation('KCHPOOL0014E',
<br>
+ {'type':
orig_pool['type']})
<br>
+
<br>
+ # new volume name:
<UUID>-<loop-index>.<original extension>
<br>
+ # e.g. 1234-5678-9012-3456-0.img
<br>
+ ext = os.path.splitext(path)[1]
<br>
+ new_vol_name = u'%s-%d%s' % (uuid, i, ext)
<br>
+ new_vol_params = {'name': new_vol_name,
'volume_path': path}
<br>
+ task = self.storagevolumes.create(new_pool_name,
new_vol_params)
<br>
+ self.task.wait(task['id'], 3600) # 1 h
<br>
+
<br>
+ # get the new volume path and update the XML
descriptor
<br>
+ new_vol = self.storagevolume.lookup(new_pool_name,
new_vol_name)
<br>
+ xml = xml_item_update(xml,
XPATH_DOMAIN_DISK_BY_FILE % path,
<br>
+ new_vol['path'], 'file')
<br>
+
<br>
+ # remove the new volume should an error occur later
<br>
+ rollback.prependDefer(self.storagevolume.delete,
new_pool_name,
<br>
+ new_vol_name)
<br>
+
<br>
+ return xml
<br>
+
<br>
+ def _clone_update_objstore(self, dom, new_uuid, rollback):
<br>
+ """Update Kimchi's object store with the cloning VM.
<br>
+
<br>
+ Arguments:
<br>
+ dom -- The native domain object of the original VM.
<br>
+ new_uuid -- The UUID of the new, clonning VM.
<br>
+ rollback -- A rollback context so the object store
entry can be removed
<br>
+ if an error occurs during the cloning operation.
<br>
+ """
<br>
+ xml =
dom.XMLDesc(libvirt.VIR_DOMAIN_XML_SECURE).decode('utf-8')
<br>
+ old_uuid = xpath_get_text(xml, XPATH_DOMAIN_UUID)[0]
<br>
+
<br>
+ with self.objstore as session:
<br>
+ try:
<br>
+ vm = session.get('vm', old_uuid)
<br>
+ icon = vm['icon']
<br>
+ session.store('vm', new_uuid, {'icon': icon})
<br>
+ except NotFoundError:
<br>
+ # if we cannot find an object store entry for
the original VM,
<br>
+ # don't store one with an empty value.
<br>
+ pass
<br>
+ else:
<br>
+ # we need to define a custom function to
prepend to the
<br>
+ # rollback context because the object store
session needs to be
<br>
+ # opened and closed correctly (i.e.
"prependDefer" only
<br>
+ # accepts one command at a time but we need
more than one to
<br>
+ # handle an object store).
<br>
+ def _rollback_objstore():
<br>
+ with self.objstore as session_rb:
<br>
+ session_rb.delete('vm', new_uuid,
ignore_missing=True)
<br>
+
<br>
+ # remove the new object store entry should an
error occur later
<br>
+ rollback.prependDefer(_rollback_objstore)
<br>
+
<br>
def _build_access_elem(self, users, groups):
<br>
access = E.access()
<br>
for user in users:
<br>
</blockquote>
<br>
_______________________________________________
<br>
Kimchi-devel mailing list
<br>
<a class="moz-txt-link-abbreviated" href="mailto:Kimchi-devel@ovirt.org">Kimchi-devel@ovirt.org</a>
<br>
<a class="moz-txt-link-freetext" href="http://lists.ovirt.org/mailman/listinfo/kimchi-devel">http://lists.ovirt.org/mailman/listinfo/kimchi-devel</a>
<br>
</blockquote>
<br>
</body>
</html>