From 504cb0565836376b4d271a4d8324d3684b7f39fb Mon Sep 17 00:00:00 2001 From: Monty Taylor Date: Tue, 31 Jan 2017 12:19:07 -0600 Subject: [PATCH] Add ability to create image from volume OpenStack has the ability to create an image from a volume - expose it. It should be noted that literally nothing about this API is documented, although it is exposed in python-cinderclient and python-openstackclient. Change-Id: Icb06d43a63d0b120a17ce6c19807abcb3de71bcb --- .../image-from-volume-9acf7379f5995b5b.yaml | 3 + shade/openstackcloud.py | 55 ++++++++++++++++-- shade/tests/functional/test_volume.py | 41 ++++++++++--- shade/tests/unit/test_image.py | 58 +++++++++++++++++++ 4 files changed, 145 insertions(+), 12 deletions(-) create mode 100644 releasenotes/notes/image-from-volume-9acf7379f5995b5b.yaml diff --git a/releasenotes/notes/image-from-volume-9acf7379f5995b5b.yaml b/releasenotes/notes/image-from-volume-9acf7379f5995b5b.yaml new file mode 100644 index 000000000..6461f5edf --- /dev/null +++ b/releasenotes/notes/image-from-volume-9acf7379f5995b5b.yaml @@ -0,0 +1,3 @@ +--- +features: + - Added ability to create an image from a volume. diff --git a/shade/openstackcloud.py b/shade/openstackcloud.py index 83b38e72d..4c2da8379 100644 --- a/shade/openstackcloud.py +++ b/shade/openstackcloud.py @@ -3267,7 +3267,7 @@ class OpenStackCloud(_normalize.Normalizer): disk_format=None, container_format=None, disable_vendor_agent=True, wait=False, timeout=3600, - allow_duplicates=False, meta=None, **kwargs): + allow_duplicates=False, meta=None, volume=None, **kwargs): """Upload an image to Glance. :param str name: Name of the image to create. If it is a pathname @@ -3303,6 +3303,9 @@ class OpenStackCloud(_normalize.Normalizer): image name. (optional, defaults to False) :param meta: A dict of key/value pairs to use for metadata that bypasses automatic type conversion. + :param volume: Name or ID or volume object of a volume to create an + image from. Mutually exclusive with (optional, defaults + to None) Additional kwargs will be passed to the image creation as additional metadata for the image and will have all values converted to string @@ -3326,10 +3329,6 @@ class OpenStackCloud(_normalize.Normalizer): if not meta: meta = {} - # If there is no filename, see if name is actually the filename - if not filename: - name, filename = self._get_name_and_filename(name) - if not disk_format: disk_format = self.cloud_config.config['image_format'] if not container_format: @@ -3337,6 +3336,26 @@ class OpenStackCloud(_normalize.Normalizer): container_format = 'ovf' else: container_format = 'bare' + + if volume: + if 'id' in volume: + volume_id = volume['id'] + else: + volume_obj = self.get_volume(volume) + if not volume_obj: + raise OpenStackCloudException( + "Volume {volume} given to create_image could" + " not be foud".format(volume=volume)) + volume_id = volume_obj['id'] + return self._upload_image_from_volume( + name=name, volume_id=volume_id, + allow_duplicates=allow_duplicates, + container_format=container_format, disk_format=disk_format, + wait=wait, timeout=timeout) + + # If there is no filename, see if name is actually the filename + if not filename: + name, filename = self._get_name_and_filename(name) if not (md5 or sha256): (md5, sha256) = self._get_file_hashes(filename) if allow_duplicates: @@ -3419,6 +3438,32 @@ class OpenStackCloud(_normalize.Normalizer): ret.update(meta) return ret + def _upload_image_from_volume( + self, name, volume_id, allow_duplicates, + container_format, disk_format, wait, timeout): + response = self._volume_client.post( + '/volumes/{id}/action'.format(id=volume_id), + json={ + 'os-volume_upload_image': { + 'force': allow_duplicates, + 'image_name': name, + 'container_format': container_format, + 'disk_format': disk_format}}) + if not wait: + return self.get_image(response['image_id']) + try: + for count in _utils._iterate_timeout( + timeout, + "Timeout waiting for the image to finish."): + image_obj = self.get_image(response['image_id']) + if image_obj and image_obj.status not in ('queued', 'saving'): + return image_obj + except OpenStackCloudTimeout: + self.log.debug( + "Timeout waiting for image to become ready. Deleting.") + self.delete_image(response['image_id'], wait=True) + raise + def _upload_image_put_v2(self, name, image_data, meta, **image_kwargs): properties = image_kwargs.pop('properties', {}) diff --git a/shade/tests/functional/test_volume.py b/shade/tests/functional/test_volume.py index 23aa8cbbf..a625b7a7a 100644 --- a/shade/tests/functional/test_volume.py +++ b/shade/tests/functional/test_volume.py @@ -34,7 +34,7 @@ class TestVolume(base.BaseFunctionalTestCase): volume_name = self.getUniqueString() snapshot_name = self.getUniqueString() self.addDetail('volume', content.text_content(volume_name)) - self.addCleanup(self.cleanup, volume_name, snapshot_name) + self.addCleanup(self.cleanup, volume_name, snapshot_name=snapshot_name) volume = self.demo_cloud.create_volume( display_name=volume_name, size=1) snapshot = self.demo_cloud.create_volume_snapshot( @@ -56,11 +56,38 @@ class TestVolume(base.BaseFunctionalTestCase): self.demo_cloud.delete_volume_snapshot(snapshot_name, wait=True) self.demo_cloud.delete_volume(volume_name, wait=True) - def cleanup(self, volume_name, snapshot_name): - volume = self.demo_cloud.get_volume(volume_name) - snapshot = self.demo_cloud.get_volume_snapshot(snapshot_name) + def test_volume_to_image(self): + '''Test volume export to image functionality''' + volume_name = self.getUniqueString() + image_name = self.getUniqueString() + self.addDetail('volume', content.text_content(volume_name)) + self.addCleanup(self.cleanup, volume_name, image_name=image_name) + volume = self.demo_cloud.create_volume( + display_name=volume_name, size=1) + image = self.demo_cloud.create_image( + image_name, volume=volume, wait=True) + + volume_ids = [v['id'] for v in self.demo_cloud.list_volumes()] + self.assertIn(volume['id'], volume_ids) + + image_list = self.demo_cloud.list_images() + image_ids = [s['id'] for s in image_list] + self.assertIn(image['id'], image_ids) + + self.demo_cloud.delete_image(image_name, wait=True) + self.demo_cloud.delete_volume(volume_name, wait=True) + + def cleanup(self, volume_name, snapshot_name=None, image_name=None): # Need to delete snapshots before volumes - if snapshot: - self.demo_cloud.delete_volume_snapshot(snapshot_name) + if snapshot_name: + snapshot = self.demo_cloud.get_volume_snapshot(snapshot_name) + if snapshot: + self.demo_cloud.delete_volume_snapshot( + snapshot_name, wait=True) + if image_name: + image = self.demo_cloud.get_image(image_name) + if image: + self.demo_cloud.delete_image(image_name, wait=True) + volume = self.demo_cloud.get_volume(volume_name) if volume: - self.demo_cloud.delete_volume(volume_name) + self.demo_cloud.delete_volume(volume_name, wait=True) diff --git a/shade/tests/unit/test_image.py b/shade/tests/unit/test_image.py index 34ec5d2b9..551ae815a 100644 --- a/shade/tests/unit/test_image.py +++ b/shade/tests/unit/test_image.py @@ -29,6 +29,7 @@ from shade.tests.unit import base NO_MD5 = '93b885adfe0da089cdf634904fd59f71' NO_SHA256 = '6e340b9cffb37a989ca544e6bb780a2c78901d3fb33738768511a30617afa01d' +CINDER_URL = 'https://volume.example.com/v2/1c36b64c840a42cd9e9b931a369337f0' class BaseTestImage(base.RequestsMockTestCase): @@ -726,3 +727,60 @@ class TestImageVersionDiscovery(BaseTestImage): self.cloud._image_client.endpoint_override, 'https://image.example.com/v2/override') self.assert_calls() + + +class TestImageVolume(BaseTestImage): + + def test_create_image_volume(self): + + volume_id = 'some-volume' + + self.register_uri( + 'POST', '{endpoint}/volumes/{id}/action'.format( + endpoint=CINDER_URL, id=volume_id), + json={'os-volume_upload_image': {'image_id': self.image_id}}, + validate=dict(json={ + u'os-volume_upload_image': { + u'container_format': u'bare', + u'disk_format': u'qcow2', + u'force': False, + u'image_name': u'fake_image'}})) + + self.use_glance() + + self.register_uri( + 'GET', 'https://image.example.com/v2/images', + json=self.fake_search_return) + + self.cloud.create_image( + 'fake_image', self.imagefile.name, wait=True, timeout=1, + volume={'id': volume_id}) + + self.assert_calls() + + def test_create_image_volume_duplicate(self): + + volume_id = 'some-volume' + + self.register_uri( + 'POST', '{endpoint}/volumes/{id}/action'.format( + endpoint=CINDER_URL, id=volume_id), + json={'os-volume_upload_image': {'image_id': self.image_id}}, + validate=dict(json={ + u'os-volume_upload_image': { + u'container_format': u'bare', + u'disk_format': u'qcow2', + u'force': True, + u'image_name': u'fake_image'}})) + + self.use_glance() + + self.register_uri( + 'GET', 'https://image.example.com/v2/images', + json=self.fake_search_return) + + self.cloud.create_image( + 'fake_image', self.imagefile.name, wait=True, timeout=1, + volume={'id': volume_id}, allow_duplicates=True) + + self.assert_calls()