diff --git a/nova/exception.py b/nova/exception.py index 7cb2cd564f..f79cf220f4 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 91a3c77b01..1685681fca 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 59f5710d49..004da283c7 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]})