In order to clone VMs, we will need to be able to clone storage volumes.
Add a new method to clone a storage volume, e.g.:
POST /storagepools/<pool-name>/storagevolumes/<volume-name>/clone
{'pool': 'another-pool', 'name': 'new-name'}
This method uses the parameters 'pool' and 'name' which specify the
destination
storage pool and the volume name. If 'pool' is ommited, the new volume will be
cloned to the same pool as the original volume; if 'name' is ommited, a default
value will be used.
Signed-off-by: Crístian Viana <vianac(a)linux.vnet.ibm.com>
---
docs/API.md | 3 ++
src/kimchi/control/storagevolumes.py | 1 +
src/kimchi/i18n.py | 1 +
src/kimchi/mockmodel.py | 45 ++++++++++++++++-
src/kimchi/model/storagevolumes.py | 97 +++++++++++++++++++++++++++++++++++-
src/kimchi/utils.py | 41 +++++++++++++++
tests/test_model.py | 18 +++++++
tests/test_rest.py | 23 +++++++++
8 files changed, 226 insertions(+), 3 deletions(-)
diff --git a/docs/API.md b/docs/API.md
index 9c06f85..b80dbe7 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -478,6 +478,9 @@ A interface represents available network interface on VM.
* size: resize the total space which can be used to store data
The unit is MBytes
* wipe: Wipe a Storage Volume
+* clone: Clone a Storage Volume.
+ * pool: The name of the destination pool (optional).
+ * name: The new storage volume name (optional).
### Collection: Interfaces
diff --git a/src/kimchi/control/storagevolumes.py b/src/kimchi/control/storagevolumes.py
index 79170ee..9f5fcea 100644
--- a/src/kimchi/control/storagevolumes.py
+++ b/src/kimchi/control/storagevolumes.py
@@ -47,6 +47,7 @@ class StorageVolume(Resource):
self.uri_fmt = '/storagepools/%s/storagevolumes/%s'
self.resize = self.generate_action_handler('resize', ['size'])
self.wipe = self.generate_action_handler('wipe')
+ 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 10408bf..2aa6d5e 100644
--- a/src/kimchi/i18n.py
+++ b/src/kimchi/i18n.py
@@ -207,6 +207,7 @@ messages = {
"KCHVOL0020E": _("Storage volume capacity must be an integer
number."),
"KCHVOL0021E": _("Storage volume URL must be http://, https://, ftp://
or ftps://."),
"KCHVOL0022E": _("Unable to access file %(url)s. Please, check
it."),
+ "KCHVOL0023E": _("Unable to clone storage volume '%(name)s' in
pool '%(pool)s'. Details: %(err)s"),
"KCHIFACE0001E": _("Interface %(name)s does not exist"),
diff --git a/src/kimchi/mockmodel.py b/src/kimchi/mockmodel.py
index 7163f8d..ffddb78 100644
--- a/src/kimchi/mockmodel.py
+++ b/src/kimchi/mockmodel.py
@@ -52,8 +52,8 @@ from kimchi.model.storageservers import STORAGE_SERVERS
from kimchi.model.utils import get_vm_name
from kimchi.objectstore import ObjectStore
from kimchi.screenshot import VMScreenshot
-from kimchi.utils import pool_name_from_uri, validate_repo_url
-from kimchi.utils import template_name_from_uri
+from kimchi.utils import get_next_clone_name, pool_name_from_uri
+from kimchi.utils import validate_repo_url, template_name_from_uri
from kimchi.vmtemplate import VMTemplate
@@ -588,6 +588,47 @@ class MockModel(object):
cb('OK', True)
+ def storagevolume_clone(self, pool, name, new_pool=None, new_name=None):
+ if new_name is None:
+ base, ext = os.path.splitext(name)
+ new_name = get_next_clone_name(self.vms_get_list(), base, ext)
+
+ if new_pool is None:
+ new_pool = pool
+
+ params = {'name': name,
+ 'pool': pool,
+ 'new_name': new_name,
+ 'new_pool': new_pool}
+ taskid = self.add_task('/storagepools/%s/storagevolumes/%s' %
+ (new_pool, new_name),
+ self._storagevolume_clone_task, params)
+ return self.task_lookup(taskid)
+
+ def _storagevolume_clone_task(self, cb, params):
+ try:
+ vol_name = params['name'].decode('utf-8')
+ pool_name = params['pool'].decode('utf-8')
+ new_vol_name = params['new_name'].decode('utf-8')
+ new_pool_name = params['new_pool'].decode('utf-8')
+
+ orig_pool = self._get_storagepool(pool_name)
+ orig_vol = self._get_storagevolume(pool_name, vol_name)
+
+ new_vol = copy.deepcopy(orig_vol)
+ new_vol.info['name'] = new_vol_name
+ new_vol.info['path'] = os.path.join(orig_pool.info['path'],
+ new_vol_name)
+
+ new_pool = self._get_storagepool(new_pool_name)
+ new_pool._volumes[new_vol_name] = new_vol
+ except (KeyError, NotFoundError), e:
+ raise OperationFailed('KCHVOL0023E',
+ {'name': vol_name, 'pool': pool_name,
+ 'err': e.message})
+
+ cb('OK', True)
+
def storagevolume_lookup(self, pool, name):
if self._get_storagepool(pool).info['state'] != 'active':
raise InvalidOperation("KCHVOL0005E", {'pool': pool,
diff --git a/src/kimchi/model/storagevolumes.py b/src/kimchi/model/storagevolumes.py
index 9ff43e6..d610059 100644
--- a/src/kimchi/model/storagevolumes.py
+++ b/src/kimchi/model/storagevolumes.py
@@ -18,9 +18,11 @@
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
import contextlib
+import lxml.etree as ET
import os
import time
import urllib2
+from lxml.builder import E
import libvirt
@@ -31,7 +33,7 @@ from kimchi.isoinfo import IsoImage
from kimchi.model.storagepools import StoragePoolModel
from kimchi.model.tasks import TaskModel
from kimchi.model.vms import VMsModel, VMModel
-from kimchi.utils import add_task, kimchi_log
+from kimchi.utils import add_task, get_next_clone_name, kimchi_log
from kimchi.xmlutils.disk import get_vm_disk_info, get_vm_disks
from kimchi.xmlutils.utils import xpath_get_text
@@ -238,6 +240,8 @@ class StorageVolumeModel(object):
def __init__(self, **kargs):
self.conn = kargs['conn']
self.objstore = kargs['objstore']
+ self.task = TaskModel(**kargs)
+ self.storagevolumes = StorageVolumesModel(**kargs)
def _get_storagevolume(self, poolname, name):
pool = StoragePoolModel.get_storagepool(poolname, self.conn)
@@ -345,6 +349,97 @@ class StorageVolumeModel(object):
raise OperationFailed("KCHVOL0011E",
{'name': name, 'err':
e.get_error_message()})
+ def clone(self, pool, name, new_pool=None, new_name=None):
+ """Clone a storage volume.
+
+ Arguments:
+ pool -- The name of the original pool.
+ name -- The name of the original volume.
+ new_pool -- The name of the destination pool (optional). If omitted,
+ the new volume will be created on the same pool as the
+ original one.
+ new_name -- The name of the new volume (optional). If omitted, a new
+ value based on the original volume's name will be used.
+
+ Return:
+ A Task running the clone operation.
+ """
+ pool = pool.decode('utf-8')
+ name = name.decode('utf-8')
+
+ # the same pool will be used if no pool is specified
+ if new_pool is None:
+ new_pool = pool
+
+ # a default name based on the original name will be used if no name
+ # is specified
+ if new_name is None:
+ base, ext = os.path.splitext(name)
+ new_name = get_next_clone_name(self.storagevolumes.get_list(pool),
+ base, ext)
+
+ params = {'pool': pool,
+ 'name': name,
+ 'new_pool': new_pool,
+ 'new_name': new_name}
+ taskid = add_task(u'/storagepools/%s/storagevolumes/%s' %
+ (pool, new_name), self._clone_task, self.objstore,
+ params)
+ return self.task.lookup(taskid)
+
+ def _clone_task(self, cb, params):
+ """Asynchronous function which performs the clone operation.
+
+ This function copies all the data inside the original volume into the
+ new one.
+
+ Arguments:
+ cb -- A callback function to signal the Task's progress.
+ params -- A dict with the following values:
+ "pool": The name of the original pool.
+ "name": The name of the original volume.
+ "new_pool": The name of the destination pool.
+ "new_name": The name of the new volume.
+ """
+ orig_pool_name = params['pool'].decode('utf-8')
+ orig_vol_name = params['name'].decode('utf-8')
+ new_pool_name = params['new_pool'].decode('utf-8')
+ new_vol_name = params['new_name'].decode('utf-8')
+
+ try:
+ cb('setting up volume cloning')
+ orig_vir_vol = self._get_storagevolume(orig_pool_name,
+ orig_vol_name)
+ orig_vol = self.lookup(orig_pool_name, orig_vol_name)
+ new_vir_pool = StoragePoolModel.get_storagepool(new_pool_name,
+ self.conn)
+
+ cb('building volume XML')
+ root_elem = E.volume()
+ root_elem.append(E.name(new_vol_name))
+ root_elem.append(E.capacity(unicode(orig_vol['capacity']),
+ unit='bytes'))
+ target_elem = E.target()
+ target_elem.append(E.format(type=orig_vol['format']))
+ root_elem.append(target_elem)
+ new_vol_xml = ET.tostring(root_elem, encoding='utf-8',
+ pretty_print=True)
+
+ cb('cloning volume')
+ new_vir_pool.createXMLFrom(new_vol_xml, orig_vir_vol, 0)
+ except (InvalidOperation, NotFoundError, libvirt.libvirtError), e:
+ raise OperationFailed('KCHVOL0023E',
+ {'name': orig_vol_name,
+ 'pool': orig_pool_name,
+ 'err': e.get_error_message()})
+
+ cb('adding volume to the object store')
+ new_vol_id = '%s:%s' % (new_pool_name, new_vol_name)
+ with self.objstore as session:
+ session.store('storagevolume', new_vol_id, {'ref_cnt': 0})
+
+ cb('OK', True)
+
class IsoVolumesModel(object):
def __init__(self, **kargs):
diff --git a/src/kimchi/utils.py b/src/kimchi/utils.py
index 0977b9f..68415dc 100644
--- a/src/kimchi/utils.py
+++ b/src/kimchi/utils.py
@@ -310,3 +310,44 @@ def validate_repo_url(url):
raise InvalidParameter("KCHUTILS0001E", {'uri': url})
else:
raise InvalidParameter("KCHREPOS0002E")
+
+
+def get_next_clone_name(all_names, basename, name_suffix=''):
+ """Find the next available name for a cloned resource.
+
+ If any resource named
"<basename>-clone-<number><name_suffix>" is found
+ in "all_names", use the maximum "number" + 1; else, use 1.
+
+ Arguments:
+ all_names -- All existing names for the resource type. This list will
+ be used to make sure the new name won't conflict with
+ existing names.
+ basename -- The name of the original resource.
+ name_suffix -- The resource name suffix (optional). This parameter
+ exist so that a resource named "foo.img" gets the name
+ "foo-clone-1.img" instead of "foo.img-clone-1". If this
parameter
+ is used, the suffix should not be present in "basename".
+
+ Return:
+ A UTF-8 string in the format
"<basename>-clone-<number><name_suffix>".
+ """
+ re_group_num = 'num'
+
+ re_expr = u'%s-clone-(?P<%s>\d+)' % (basename, re_group_num)
+ if name_suffix != '':
+ re_expr = u'%s-%s' % (re_expr, name_suffix)
+
+ max_num = 0
+ re_compiled = re.compile(re_expr)
+
+ for n in all_names:
+ match = re_compiled.match(n)
+ if match is not None:
+ max_num = max(max_num, int(match.group(re_group_num)))
+
+ # increments the maximum "clone number" found
+ new_name = u'%s-clone-%d' % (basename, max_num + 1)
+ if name_suffix != '':
+ new_name = new_name + name_suffix
+
+ return new_name
diff --git a/tests/test_model.py b/tests/test_model.py
index bd41c79..32777b1 100644
--- a/tests/test_model.py
+++ b/tests/test_model.py
@@ -623,6 +623,24 @@ class ModelTests(unittest.TestCase):
cp_content = cp_file.read()
self.assertEquals(vol_content, cp_content)
+ # clone the volume created above
+ task = inst.storagevolume_clone(pool, vol_name)
+ taskid = task['id']
+ cloned_vol_name = task['target_uri'].split('/')[-1]
+ inst.task_wait(taskid)
+ self.assertEquals('finished',
inst.task_lookup(taskid)['status'])
+ rollback.prependDefer(inst.storagevolume_delete, pool,
+ cloned_vol_name)
+
+ orig_vol = inst.storagevolume_lookup(pool, vol_name)
+ cloned_vol = inst.storagevolume_lookup(pool, cloned_vol_name)
+
+ self.assertNotEquals(orig_vol['path'], cloned_vol['path'])
+ del orig_vol['path']
+ del cloned_vol['path']
+
+ self.assertEquals(orig_vol, cloned_vol)
+
@unittest.skipUnless(utils.running_as_root(), 'Must be run as root')
def test_template_storage_customise(self):
inst = model.Model(objstore_loc=self.tmp_store)
diff --git a/tests/test_rest.py b/tests/test_rest.py
index 9bc930f..3cf9e2b 100644
--- a/tests/test_rest.py
+++ b/tests/test_rest.py
@@ -1047,6 +1047,29 @@ class RestTests(unittest.TestCase):
resp = self.request('/storagepools/pool-1/storagevolumes/%s' %
vol_name, '{}', 'GET')
self.assertEquals(200, resp.status)
+ vol = json.loads(resp.read())
+
+ # clone the volume created above
+ resp = self.request('/storagepools/pool-1/storagevolumes/%s/clone' %
+ vol_name, {}, 'POST')
+ self.assertEquals(202, resp.status)
+ task = json.loads(resp.read())
+ cloned_vol_name = task['target_uri'].split('/')[-1]
+ wait_task(self._task_lookup, task['id'])
+ task = json.loads(self.request('/tasks/%s' % task['id']).read())
+ self.assertEquals('finished', task['status'])
+ resp = self.request('/storagepools/pool-1/storagevolumes/%s' %
+ cloned_vol_name, '{}', 'GET')
+ self.assertEquals(200, resp.status)
+ cloned_vol = json.loads(resp.read())
+
+ self.assertNotEquals(vol['name'], cloned_vol['name'])
+ del vol['name']
+ del cloned_vol['name']
+ self.assertNotEquals(vol['path'], cloned_vol['path'])
+ del vol['path']
+ del cloned_vol['path']
+ self.assertEquals(vol, cloned_vol)
# Now remove the StoragePool from mock model
self._delete_pool('pool-1')
--
1.9.3