From 4a6a366b05f21562a4b934e47507eaa5bbefb828 Mon Sep 17 00:00:00 2001 From: Dan Smith Date: Wed, 27 May 2020 09:21:45 -0700 Subject: [PATCH] Plumb image import functionality through our glance module This just provides minimal support for calling the import API in Glance. That API can do more things, but it is unlikely Nova would ever need to call them, so this is rather opinionated and could be extended later if needed. Related to blueprint rbd-glance-multistore Change-Id: Icf78fcabad8b966b6b5c289e1b660c01c928272d --- nova/exception.py | 4 ++ nova/image/glance.py | 45 +++++++++++++++++ nova/tests/unit/image/test_glance.py | 74 ++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+) diff --git a/nova/exception.py b/nova/exception.py index 7cb2cd564f98..f79cf220f422 100644 --- a/nova/exception.py +++ b/nova/exception.py @@ -591,6 +591,10 @@ class ImageBadRequest(Invalid): "%(response)s") +class ImageImportImpossible(Invalid): + msg_fmt = _("Import of image %(image_id)s refused: %(reason)s") + + class ImageQuotaExceeded(NovaException): msg_fmt = _("Quota exceeded or out of space for image %(image_id)s " "in the image service.") diff --git a/nova/image/glance.py b/nova/image/glance.py index 91a3c77b01c7..1685681fca39 100644 --- a/nova/image/glance.py +++ b/nova/image/glance.py @@ -654,6 +654,41 @@ class GlanceImageServiceV2(object): raise exception.ImageDeleteConflict(reason=six.text_type(exc)) return True + def image_import_copy(self, context, image_id, stores): + """Copy an image to another store using image_import. + + This triggers the Glance image_import API with an opinionated + method of 'copy-image' to a list of stores. This will initiate + a copy of the image from one of the existing stores to the + stores provided. + + :param context: The RequestContext + :param image_id: The image to copy + :param stores: A list of stores to copy the image to + + :raises: ImageNotFound if the image does not exist. + :raises: ImageNotAuthorized if the user is not permitted to + import/copy this image + :raises: ImageImportImpossible if the image cannot be imported + for workflow reasons (not active, etc) + :raises: ImageBadRequest if the image is already in the requested + store (which may be a race) + """ + try: + self._client.call(context, 2, 'image_import', args=(image_id,), + kwargs={'method': 'copy-image', + 'stores': stores}) + except glanceclient.exc.NotFound: + raise exception.ImageNotFound(image_id=image_id) + except glanceclient.exc.HTTPForbidden: + raise exception.ImageNotAuthorized(image_id=image_id) + except glanceclient.exc.HTTPConflict as exc: + raise exception.ImageImportImpossible(image_id=image_id, + reason=str(exc)) + except glanceclient.exc.HTTPBadRequest as exc: + raise exception.ImageBadRequest(image_id=image_id, + response=str(exc)) + def _extract_query_params_v2(params): _params = {} @@ -1189,3 +1224,13 @@ class API(object): return session.download(context, image_id, data=data, dst_path=dest_path, trusted_certs=trusted_certs) + + def copy_image_to_store(self, context, image_id, store): + """Initiate a store-to-store copy in glance. + + :param context: The RequestContext. + :param image_id: The image to copy. + :param store: The glance store to target the copy. + """ + session, image_id = self._get_session_and_image_id(context, image_id) + return session.image_import_copy(context, image_id, [store]) diff --git a/nova/tests/unit/image/test_glance.py b/nova/tests/unit/image/test_glance.py index 59f5710d4929..004da283c7f3 100644 --- a/nova/tests/unit/image/test_glance.py +++ b/nova/tests/unit/image/test_glance.py @@ -2087,3 +2087,77 @@ class TestSafeFSync(test.NoDBTestCase): """Validate fsync not called for socket.""" self.common(mock_isfifo, False, mock_issock, True, mock_fstat) mock_fsync.assert_not_called() + + +class TestImportCopy(test.NoDBTestCase): + + """Tests the image import/copy methods.""" + + def _test_import(self, exception=None): + client = mock.MagicMock() + if exception: + client.call.side_effect = exception + else: + client.call.return_value = True + ctx = mock.sentinel.ctx + service = glance.GlanceImageServiceV2(client) + service.image_import_copy(ctx, mock.sentinel.image_id, + [mock.sentinel.store]) + return client + + def test_image_import_copy_success(self): + client = self._test_import() + client.call.assert_called_once_with( + mock.sentinel.ctx, 2, 'image_import', + args=(mock.sentinel.image_id,), + kwargs={'method': 'copy-image', + 'stores': [mock.sentinel.store]}) + + def test_image_import_copy_not_found(self): + self.assertRaises(exception.ImageNotFound, + self._test_import, + glanceclient.exc.NotFound) + + def test_image_import_copy_not_authorized(self): + self.assertRaises(exception.ImageNotAuthorized, + self._test_import, + glanceclient.exc.HTTPForbidden) + + def test_image_import_copy_failed_workflow(self): + self.assertRaises(exception.ImageImportImpossible, + self._test_import, + glanceclient.exc.HTTPConflict) + + def test_image_import_copy_failed_already_imported(self): + self.assertRaises(exception.ImageBadRequest, + self._test_import, + glanceclient.exc.HTTPBadRequest) + + def test_api(self): + api = glance.API() + with mock.patch.object(api, '_get_session_and_image_id') as g: + session = mock.MagicMock() + g.return_value = session, mock.sentinel.image_id + api.copy_image_to_store(mock.sentinel.ctx, + mock.sentinel.image_id, + mock.sentinel.store) + session.image_import_copy.assert_called_once_with( + mock.sentinel.ctx, mock.sentinel.image_id, + [mock.sentinel.store]) + + def test_api_to_client(self): + # Test all the way down to the client to test the interface between + # API and GlanceImageServiceV2 + wrapper = mock.MagicMock() + client = glance.GlanceImageServiceV2(client=wrapper) + api = glance.API() + with mock.patch.object(api, '_get_session_and_image_id') as m: + m.return_value = (client, mock.sentinel.image_id) + api.copy_image_to_store(mock.sentinel.ctx, + mock.sentinel.image_id, + mock.sentinel.store) + wrapper.call.assert_called_once_with( + mock.sentinel.ctx, 2, 'image_import', + args=(mock.sentinel.image_id,), + kwargs={'method': 'copy-image', + 'stores': [mock.sentinel.store]})