]> review.fuel-infra Code Review - openstack-build/cinder-build.git/commitdiff
Export and import backup service metadata
authorRonen Kat <ronenkat@il.ibm.com>
Thu, 23 Jan 2014 09:58:58 +0000 (11:58 +0200)
committerRonen Kat <ronenkat@il.ibm.com>
Thu, 27 Feb 2014 10:38:10 +0000 (12:38 +0200)
Add new admin API for backup-export and backup-import.
The new commands export the backup details (not actual backup) to
a string that can be imported again in another OpenStack cloud or
if the backup database was corrupted.
The code includes a default backup driver implementation.
Backup test code converted to use mock.

blueprint cinder-backup-recover-api
DocImpact new admin API calls backup-import and backup-export

Change-Id: I564194929962e75c67630e73d8711ee6587706d4

cinder/api/contrib/backups.py
cinder/api/views/backups.py
cinder/backup/api.py
cinder/backup/driver.py
cinder/backup/manager.py
cinder/backup/rpcapi.py
cinder/tests/api/contrib/test_backups.py
cinder/tests/policy.json
cinder/tests/test_backup.py
cinder/tests/test_backup_driver_base.py
etc/cinder/policy.json

index 07c7e3a0395d452f746e6b6aedf41056cb612191..405b874660e2aa9e0e11b939af2c59a8fbeaf801 100644 (file)
@@ -29,7 +29,6 @@ from cinder import exception
 from cinder.openstack.common import log as logging
 from cinder import utils
 
-
 LOG = logging.getLogger(__name__)
 
 
@@ -52,6 +51,11 @@ def make_backup_restore(elem):
     elem.set('volume_id')
 
 
+def make_backup_export_import_record(elem):
+    elem.set('backup_service')
+    elem.set('backup_url')
+
+
 class BackupTemplate(xmlutil.TemplateBuilder):
     def construct(self):
         root = xmlutil.TemplateElement('backup', selector='backup')
@@ -80,6 +84,16 @@ class BackupRestoreTemplate(xmlutil.TemplateBuilder):
         return xmlutil.MasterTemplate(root, 1, nsmap={alias: namespace})
 
 
+class BackupExportImportTemplate(xmlutil.TemplateBuilder):
+    def construct(self):
+        root = xmlutil.TemplateElement('backup-record',
+                                       selector='backup-record')
+        make_backup_export_import_record(root)
+        alias = Backups.alias
+        namespace = Backups.namespace
+        return xmlutil.MasterTemplate(root, 1, nsmap={alias: namespace})
+
+
 class CreateDeserializer(wsgi.MetadataXMLDeserializer):
     def default(self, string):
         dom = utils.safe_minidom_parse_string(string)
@@ -113,6 +127,25 @@ class RestoreDeserializer(wsgi.MetadataXMLDeserializer):
         return restore
 
 
+class BackupImportDeserializer(wsgi.MetadataXMLDeserializer):
+    def default(self, string):
+        dom = utils.safe_minidom_parse_string(string)
+        backup = self._extract_backup(dom)
+        retval = {'body': {'backup-record': backup}}
+        return retval
+
+    def _extract_backup(self, node):
+        backup = {}
+        backup_node = self.find_first_child_named(node, 'backup-record')
+
+        attributes = ['backup_service', 'backup_url']
+
+        for attr in attributes:
+            if backup_node.getAttribute(attr):
+                backup[attr] = backup_node.getAttribute(attr)
+        return backup
+
+
 class BackupsController(wsgi.Controller):
     """The Backups API controller for the OpenStack API."""
 
@@ -260,6 +293,61 @@ class BackupsController(wsgi.Controller):
             req, dict(new_restore.iteritems()))
         return retval
 
+    @wsgi.response(200)
+    @wsgi.serializers(xml=BackupExportImportTemplate)
+    def export_record(self, req, id):
+        """Export a backup."""
+        LOG.debug(_('export record called for member %s.'), id)
+        context = req.environ['cinder.context']
+
+        try:
+            backup_info = self.backup_api.export_record(context, id)
+        except exception.BackupNotFound as error:
+            raise exc.HTTPNotFound(explanation=error.msg)
+        except exception.InvalidBackup as error:
+            raise exc.HTTPBadRequest(explanation=error.msg)
+
+        retval = self._view_builder.export_summary(
+            req, dict(backup_info.iteritems()))
+        LOG.debug(_('export record output: %s.'), retval)
+        return retval
+
+    @wsgi.response(201)
+    @wsgi.serializers(xml=BackupTemplate)
+    @wsgi.deserializers(xml=BackupImportDeserializer)
+    def import_record(self, req, body):
+        """Import a backup."""
+        LOG.debug(_('Importing record from %s.'), body)
+        if not self.is_valid_body(body, 'backup-record'):
+            msg = _("Incorrect request body format.")
+            raise exc.HTTPBadRequest(explanation=msg)
+        context = req.environ['cinder.context']
+        import_data = body['backup-record']
+        #Verify that body elements are provided
+        try:
+            backup_service = import_data['backup_service']
+            backup_url = import_data['backup_url']
+        except KeyError:
+            msg = _("Incorrect request body format.")
+            raise exc.HTTPBadRequest(explanation=msg)
+        LOG.debug(_('Importing backup using %(service)s and url %(url)s.'),
+                  {'service': backup_service, 'url': backup_url})
+
+        try:
+            new_backup = self.backup_api.import_record(context,
+                                                       backup_service,
+                                                       backup_url)
+        except exception.BackupNotFound as error:
+            raise exc.HTTPNotFound(explanation=error.msg)
+        except exception.InvalidBackup as error:
+            raise exc.HTTPBadRequest(explanation=error.msg)
+        except exception.ServiceNotFound as error:
+            raise exc.HTTPInternalServerError(explanation=error.msg)
+
+        retval = self._view_builder.summary(req, dict(new_backup.iteritems()))
+        LOG.debug(_('import record output: %s.'), retval)
+        return retval
+
 
 class Backups(extensions.ExtensionDescriptor):
     """Backups support."""
@@ -273,7 +361,7 @@ class Backups(extensions.ExtensionDescriptor):
         resources = []
         res = extensions.ResourceExtension(
             Backups.alias, BackupsController(),
-            collection_actions={'detail': 'GET'},
-            member_actions={'restore': 'POST'})
+            collection_actions={'detail': 'GET', 'import_record': 'POST'},
+            member_actions={'restore': 'POST', 'export_record': 'GET'})
         resources.append(res)
         return resources
index 446bf30c616c56b94ef78a0baefb0d43d2f28b30..aafd11ef91c67cd69bd2c5c76f79a9ca3b8076a3 100644 (file)
@@ -88,3 +88,12 @@ class ViewBuilder(common.ViewBuilder):
             backups_dict['backups_links'] = backups_links
 
         return backups_dict
+
+    def export_summary(self, request, export):
+        """Generic view of an export."""
+        return {
+            'backup-record': {
+                'backup_service': export['backup_service'],
+                'backup_url': export['backup_url'],
+            },
+        }
index 6eda76ebfd2fe286f4bc5f64eec4ff6b155851f9..544cde7238d299027749c9ed1cf5e6f2fcc5a3a2 100644 (file)
@@ -96,6 +96,16 @@ class API(base.Base):
                 return True
         return False
 
+    def _list_backup_services(self):
+        """List all enabled backup services.
+
+        :returns: list -- hosts for services that are enabled for backup.
+        """
+        topic = CONF.backup_topic
+        ctxt = context.get_admin_context()
+        services = self.db.service_get_all_by_topic(ctxt, topic)
+        return [srv['host'] for srv in services if not srv['disabled']]
+
     def create(self, context, name, description, volume_id,
                container, availability_zone=None):
         """Make the RPC call to create a volume backup."""
@@ -197,3 +207,68 @@ class API(base.Base):
              'volume_id': volume_id, }
 
         return d
+
+    def export_record(self, context, backup_id):
+        """Make the RPC call to export a volume backup.
+
+        Call backup manager to execute backup export.
+
+        :param context: running context
+        :param backup_id: backup id to export
+        :returns: dictionary -- a description of how to import the backup
+        :returns: contains 'backup_url' and 'backup_service'
+        :raises: InvalidBackup
+        """
+        check_policy(context, 'backup-export')
+        backup = self.get(context, backup_id)
+        if backup['status'] != 'available':
+            msg = (_('Backup status must be available and not %s.') %
+                   backup['status'])
+            raise exception.InvalidBackup(reason=msg)
+
+        LOG.debug("Calling RPCAPI with context: "
+                  "%(ctx)s, host: %(host)s, backup: %(id)s.",
+                  {'ctx': context,
+                   'host': backup['host'],
+                   'id': backup['id']})
+        export_data = self.backup_rpcapi.export_record(context,
+                                                       backup['host'],
+                                                       backup['id'])
+
+        return export_data
+
+    def import_record(self, context, backup_service, backup_url):
+        """Make the RPC call to import a volume backup.
+
+        :param context: running context
+        :param backup_service: backup service name
+        :param backup_url: backup description to be used by the backup driver
+        :raises: InvalidBackup
+        :raises: ServiceNotFound
+        """
+        check_policy(context, 'backup-import')
+
+        # NOTE(ronenkat): since we don't have a backup-scheduler
+        # we need to find a host that support the backup service
+        # that was used to create the backup.
+        # We  send it to the first backup service host, and the backup manager
+        # on that host will forward it to other hosts on the hosts list if it
+        # cannot support correct service itself.
+        hosts = self._list_backup_services()
+        if len(hosts) == 0:
+            raise exception.ServiceNotFound(service_id=backup_service)
+
+        options = {'user_id': context.user_id,
+                   'project_id': context.project_id,
+                   'volume_id': '0000-0000-0000-0000',
+                   'status': 'creating', }
+        backup = self.db.backup_create(context, options)
+        first_host = hosts.pop()
+        self.backup_rpcapi.import_record(context,
+                                         first_host,
+                                         backup['id'],
+                                         backup_service,
+                                         backup_url,
+                                         hosts)
+
+        return backup
index 81fc0321316d2b0430a51b580030f9603d4e669e..ae1d5c41e6405dd5249d62db1135a8b000ad17d5 100644 (file)
@@ -263,3 +263,38 @@ class BackupDriver(base.Base):
     def delete(self, backup):
         """Delete a saved backup."""
         raise NotImplementedError()
+
+    def export_record(self, backup):
+        """Export backup record.
+
+        Default backup driver implementation.
+        Serialize the backup record describing the backup into a string.
+
+        :param backup: backup entry to export
+        :returns backup_url - a string describing the backup record
+        """
+        retval = jsonutils.dumps(backup)
+        return retval.encode("base64")
+
+    def import_record(self, backup_url):
+        """Import and verify backup record.
+
+        Default backup driver implementation.
+        De-serialize the backup record into a dictionary, so we can
+        update the database.
+
+        :param backup_url: driver specific backup record string
+        :returns dictionary object with database updates
+        """
+        return jsonutils.loads(backup_url.decode("base64"))
+
+    def verify(self, backup):
+        """Verify that the backup exists on the backend.
+
+        Verify that the backup is OK, possibly following an import record
+        operation.
+
+        :param backup: backup id of the backup to verify
+        :raises: InvalidBackup, NotImplementedError
+        """
+        raise NotImplementedError()
index a36790850e13bb2e743dbfb06e25d4e654b6d325..feab8b48227a10eaaf9a621ff8e3dd1a92ef8d57 100644 (file)
@@ -35,6 +35,7 @@ Volume backups can be created, restored, deleted and listed.
 
 from oslo.config import cfg
 
+from cinder.backup import rpcapi as backup_rpcapi
 from cinder import context
 from cinder import exception
 from cinder import manager
@@ -71,6 +72,7 @@ class BackupManager(manager.SchedulerDependentManager):
         self.az = CONF.storage_availability_zone
         self.volume_managers = {}
         self._setup_volume_drivers()
+        self.backup_rpcapi = backup_rpcapi.BackupAPI()
         super(BackupManager, self).__init__(service_name='backup',
                                             *args, **kwargs)
 
@@ -296,22 +298,20 @@ class BackupManager(manager.SchedulerDependentManager):
         expected_status = 'restoring-backup'
         actual_status = volume['status']
         if actual_status != expected_status:
-            err = _('Restore backup aborted: expected volume status '
-                    '%(expected_status)s but got %(actual_status)s.') % {
-                        'expected_status': expected_status,
-                        'actual_status': actual_status
-                    }
+            err = (_('Restore backup aborted, expected volume status '
+                     '%(expected_status)s but got %(actual_status)s.') %
+                   {'expected_status': expected_status,
+                    'actual_status': actual_status})
             self.db.backup_update(context, backup_id, {'status': 'available'})
             raise exception.InvalidVolume(reason=err)
 
         expected_status = 'restoring'
         actual_status = backup['status']
         if actual_status != expected_status:
-            err = _('Restore backup aborted: expected backup status '
-                    '%(expected_status)s but got %(actual_status)s.') % {
-                        'expected_status': expected_status,
-                        'actual_status': actual_status
-                    }
+            err = (_('Restore backup aborted: expected backup status '
+                     '%(expected_status)s but got %(actual_status)s.') %
+                   {'expected_status': expected_status,
+                    'actual_status': actual_status})
             self.db.backup_update(context, backup_id, {'status': 'error',
                                                        'fail_reason': err})
             self.db.volume_update(context, volume_id, {'status': 'error'})
@@ -420,3 +420,151 @@ class BackupManager(manager.SchedulerDependentManager):
         context = context.elevated()
         self.db.backup_destroy(context, backup_id)
         LOG.info(_('Delete backup finished, backup %s deleted.'), backup_id)
+
+    def export_record(self, context, backup_id):
+        """Export all volume backup metadata details to allow clean import.
+
+        Export backup metadata so it could be re-imported into the database
+        without any prerequisite in the backup database.
+
+        :param context: running context
+        :param backup_id: backup id to export
+        :returns: backup_record - a description of how to import the backup
+        :returns: contains 'backup_url' - how to import the backup, and
+        :returns: 'backup_service' describing the needed driver.
+        :raises: InvalidBackup
+        """
+        LOG.info(_('Export record started, backup: %s.'), backup_id)
+
+        backup = self.db.backup_get(context, backup_id)
+
+        expected_status = 'available'
+        actual_status = backup['status']
+        if actual_status != expected_status:
+            err = (_('Export backup aborted, expected backup status '
+                     '%(expected_status)s but got %(actual_status)s.') %
+                   {'expected_status': expected_status,
+                    'actual_status': actual_status})
+            raise exception.InvalidBackup(reason=err)
+
+        backup_record = {}
+        backup_record['backup_service'] = backup['service']
+        backup_service = self._map_service_to_driver(backup['service'])
+        configured_service = self.driver_name
+        if backup_service != configured_service:
+            err = (_('Export record aborted, the backup service currently'
+                     ' configured [%(configured_service)s] is not the'
+                     ' backup service that was used to create this'
+                     ' backup [%(backup_service)s].') %
+                   {'configured_service': configured_service,
+                    'backup_service': backup_service})
+            raise exception.InvalidBackup(reason=err)
+
+        # Call driver to create backup description string
+        try:
+            utils.require_driver_initialized(self.driver)
+            backup_service = self.service.get_backup_driver(context)
+            backup_url = backup_service.export_record(backup)
+            backup_record['backup_url'] = backup_url
+        except Exception as err:
+            msg = unicode(err)
+            raise exception.InvalidBackup(reason=msg)
+
+        LOG.info(_('Export record finished, backup %s exported.'), backup_id)
+        return backup_record
+
+    def import_record(self,
+                      context,
+                      backup_id,
+                      backup_service,
+                      backup_url,
+                      backup_hosts):
+        """Import all volume backup metadata details to the backup db.
+
+        :param context: running context
+        :param backup_id: The new backup id for the import
+        :param backup_service: The needed backup driver for import
+        :param backup_url: An identifier string to locate the backup
+        :param backup_hosts: Potential hosts to execute the import
+        :raises: InvalidBackup
+        :raises: ServiceNotFound
+        """
+        LOG.info(_('Import record started, backup_url: %s.'), backup_url)
+
+        # Can we import this backup?
+        if (backup_service != self.driver_name):
+            # No, are there additional potential backup hosts in the list?
+            if len(backup_hosts) > 0:
+                # try the next host on the list, maybe he can import
+                first_host = backup_hosts.pop()
+                self.backup_rpcapi.import_record(context,
+                                                 first_host,
+                                                 backup_id,
+                                                 backup_service,
+                                                 backup_url,
+                                                 backup_hosts)
+            else:
+                # empty list - we are the last host on the list, fail
+                err = _('Import record failed, cannot find backup '
+                        'service to perform the import. Request service '
+                        '%(service)s') % {'service': backup_service}
+                self.db.backup_update(context, backup_id, {'status': 'error',
+                                                           'fail_reason': err})
+                raise exception.ServiceNotFound(service_id=backup_service)
+        else:
+            # Yes...
+            try:
+                utils.require_driver_initialized(self.driver)
+                backup_service = self.service.get_backup_driver(context)
+                backup_options = backup_service.import_record(backup_url)
+            except Exception as err:
+                msg = unicode(err)
+                self.db.backup_update(context,
+                                      backup_id,
+                                      {'status': 'error',
+                                      'fail_reason': msg})
+                raise exception.InvalidBackup(reason=msg)
+
+            required_import_options = ['display_name',
+                                       'display_description',
+                                       'container',
+                                       'size',
+                                       'service_metadata',
+                                       'service',
+                                       'object_count']
+
+            backup_update = {}
+            backup_update['status'] = 'available'
+            backup_update['service'] = self.driver_name
+            backup_update['availability_zone'] = self.az
+            backup_update['host'] = self.host
+            for entry in required_import_options:
+                if entry not in backup_options:
+                    msg = (_('Backup metadata received from driver for '
+                             'import is missing %s.'), entry)
+                    self.db.backup_update(context,
+                                          backup_id,
+                                          {'status': 'error',
+                                           'fail_reason': msg})
+                    raise exception.InvalidBackup(reason=msg)
+                backup_update[entry] = backup_options[entry]
+            # Update the database
+            self.db.backup_update(context, backup_id, backup_update)
+
+            # Verify backup
+            try:
+                backup_service.verify(backup_id)
+            except NotImplementedError:
+                LOG.warn(_('Backup service %(service)s does not support '
+                           'verify. Backup id %(id)s is not verified. '
+                           'Skipping verify.') % {'service': self.driver_name,
+                                                  'id': backup_id})
+            except exception.InvalidBackup as err:
+                with excutils.save_and_reraise_exception():
+                    self.db.backup_update(context, backup_id,
+                                          {'status': 'error',
+                                           'fail_reason':
+                                           unicode(err)})
+
+            LOG.info(_('Import record id %s metadata from driver '
+                       'finished.') % backup_id)
index 42941ff05c95cb93f5bb78c6ebdc4c8c082976f6..ee421eb1933f64df03825695f3ab29727194f341 100644 (file)
@@ -65,9 +65,42 @@ class BackupAPI(cinder.openstack.common.rpc.proxy.RpcProxy):
                   topic=topic)
 
     def delete_backup(self, ctxt, host, backup_id):
-        LOG.debug("delete_backup  rpcapi backup_id %s", backup_id)
+        LOG.debug("delete_backup rpcapi backup_id %s", backup_id)
         topic = rpc.queue_get_for(ctxt, self.topic, host)
         self.cast(ctxt,
                   self.make_msg('delete_backup',
                                 backup_id=backup_id),
                   topic=topic)
+
+    def export_record(self, ctxt, host, backup_id):
+        LOG.debug("export_record in rpcapi backup_id %(id)s "
+                  "on host %(host)s.",
+                  {'id': backup_id,
+                   'host': host})
+        topic = rpc.queue_get_for(ctxt, self.topic, host)
+        LOG.debug("export queue topic=%s" % topic)
+        return self.call(ctxt,
+                         self.make_msg('export_record',
+                                       backup_id=backup_id),
+                         topic=topic)
+
+    def import_record(self,
+                      ctxt,
+                      host,
+                      backup_id,
+                      backup_service,
+                      backup_url,
+                      backup_hosts):
+        LOG.debug("import_record rpcapi backup id $(id)s "
+                  "on host %(host)s "
+                  "for backup_url %(url)s." % {'id': backup_id,
+                                               'host': host,
+                                               'url': backup_url})
+        topic = rpc.queue_get_for(ctxt, self.topic, host)
+        self.cast(ctxt,
+                  self.make_msg('import_record',
+                                backup_id=backup_id,
+                                backup_service=backup_service,
+                                backup_url=backup_url,
+                                backup_hosts=backup_hosts),
+                  topic=topic)
index a0474f33773a50ad5a02c050a04836ebb52c0dd1..a7dba68c073a3b4a6f75844e692893c9a746421f 100644 (file)
@@ -18,6 +18,7 @@ Tests for Backup code.
 """
 
 import json
+import mock
 from xml.dom import minidom
 
 import webob
@@ -81,11 +82,6 @@ class BackupsAPITestCase(test.TestCase):
         return db.backup_get(context.get_admin_context(),
                              backup_id)[attrib_name]
 
-    @staticmethod
-    def _stub_service_get_all_by_topic(context, topic):
-        return [{'availability_zone': "fake_az", 'host': 'test_host',
-                 'disabled': 0, 'updated_at': timeutils.utcnow()}]
-
     def test_show_backup(self):
         volume_id = utils.create_volume(self.context, size=5,
                                         status='creating')['id']
@@ -340,9 +336,11 @@ class BackupsAPITestCase(test.TestCase):
         db.backup_destroy(context.get_admin_context(), backup_id2)
         db.backup_destroy(context.get_admin_context(), backup_id1)
 
-    def test_create_backup_json(self):
-        self.stubs.Set(cinder.db, 'service_get_all_by_topic',
-                       self._stub_service_get_all_by_topic)
+    @mock.patch('cinder.db.service_get_all_by_topic')
+    def test_create_backup_json(self, _mock_service_get_all_by_topic):
+        _mock_service_get_all_by_topic.return_value = [
+            {'availability_zone': "fake_az", 'host': 'test_host',
+             'disabled': 0, 'updated_at': timeutils.utcnow()}]
 
         volume_id = utils.create_volume(self.context, size=5)['id']
 
@@ -364,12 +362,16 @@ class BackupsAPITestCase(test.TestCase):
 
         self.assertEqual(res.status_int, 202)
         self.assertIn('id', res_dict['backup'])
+        self.assertTrue(_mock_service_get_all_by_topic.called)
 
         db.volume_destroy(context.get_admin_context(), volume_id)
 
-    def test_create_backup_xml(self):
-        self.stubs.Set(cinder.db, 'service_get_all_by_topic',
-                       self._stub_service_get_all_by_topic)
+    @mock.patch('cinder.db.service_get_all_by_topic')
+    def test_create_backup_xml(self, _mock_service_get_all_by_topic):
+        _mock_service_get_all_by_topic.return_value = [
+            {'availability_zone': "fake_az", 'host': 'test_host',
+             'disabled': 0, 'updated_at': timeutils.utcnow()}]
+
         volume_id = utils.create_volume(self.context, size=2)['id']
 
         req = webob.Request.blank('/v2/fake/backups')
@@ -385,6 +387,7 @@ class BackupsAPITestCase(test.TestCase):
         dom = minidom.parseString(res.body)
         backup = dom.getElementsByTagName('backup')
         self.assertTrue(backup.item(0).hasAttribute('id'))
+        self.assertTrue(_mock_service_get_all_by_topic.called)
 
         db.volume_destroy(context.get_admin_context(), volume_id)
 
@@ -468,13 +471,13 @@ class BackupsAPITestCase(test.TestCase):
                          'Invalid volume: Volume to be backed up must'
                          ' be available')
 
-    def test_create_backup_WithOUT_enabled_backup_service(self):
+    @mock.patch('cinder.db.service_get_all_by_topic')
+    def test_create_backup_WithOUT_enabled_backup_service(
+            self,
+            _mock_service_get_all_by_topic):
         # need an enabled backup service available
-        def stub_empty_service_get_all_by_topic(ctxt, topic):
-            return []
+        _mock_service_get_all_by_topic.return_value = []
 
-        self.stubs.Set(cinder.db, 'service_get_all_by_topic',
-                       stub_empty_service_get_all_by_topic)
         volume_id = utils.create_volume(self.context, size=2)['id']
         req = webob.Request.blank('/v2/fake/backups')
         body = {"backup": {"display_name": "nightly001",
@@ -499,76 +502,72 @@ class BackupsAPITestCase(test.TestCase):
         volume = self.volume_api.get(context.get_admin_context(), volume_id)
         self.assertEqual(volume['status'], 'available')
 
-    def test_is_backup_service_enabled(self):
-        def empty_service(ctxt, topic):
-            return []
+    @mock.patch('cinder.db.service_get_all_by_topic')
+    def test_is_backup_service_enabled(self, _mock_service_get_all_by_topic):
 
         test_host = 'test_host'
         alt_host = 'strange_host'
-
+        empty_service = []
         #service host not match with volume's host
-        def host_not_match(context, topic):
-            return [{'availability_zone': "fake_az", 'host': alt_host,
-                     'disabled': 0, 'updated_at': timeutils.utcnow()}]
-
+        host_not_match = [{'availability_zone': "fake_az", 'host': alt_host,
+                           'disabled': 0, 'updated_at': timeutils.utcnow()}]
         #service az not match with volume's az
-        def az_not_match(context, topic):
-            return [{'availability_zone': "strange_az", 'host': test_host,
-                     'disabled': 0, 'updated_at': timeutils.utcnow()}]
-
+        az_not_match = [{'availability_zone': "strange_az", 'host': test_host,
+                         'disabled': 0, 'updated_at': timeutils.utcnow()}]
         #service disabled
-        def disabled_service(context, topic):
-            return [{'availability_zone': "fake_az", 'host': test_host,
-                     'disabled': 1, 'updated_at': timeutils.utcnow()}]
+        disabled_service = [{'availability_zone': "fake_az",
+                             'host': test_host,
+                             'disabled': 1,
+                             'updated_at': timeutils.utcnow()}]
 
         #dead service that last reported at 20th centry
-        def dead_service(context, topic):
-            return [{'availability_zone': "fake_az", 'host': alt_host,
-                     'disabled': 0, 'updated_at': '1989-04-16 02:55:44'}]
+        dead_service = [{'availability_zone': "fake_az", 'host': alt_host,
+                         'disabled': 0, 'updated_at': '1989-04-16 02:55:44'}]
 
         #first service's host not match but second one works.
-        def multi_services(context, topic):
-            return [{'availability_zone': "fake_az", 'host': alt_host,
-                     'disabled': 0, 'updated_at': timeutils.utcnow()},
-                    {'availability_zone': "fake_az", 'host': test_host,
-                     'disabled': 0, 'updated_at': timeutils.utcnow()}]
+        multi_services = [{'availability_zone': "fake_az", 'host': alt_host,
+                           'disabled': 0, 'updated_at': timeutils.utcnow()},
+                          {'availability_zone': "fake_az", 'host': test_host,
+                           'disabled': 0, 'updated_at': timeutils.utcnow()}]
+
+        #Setup mock to run through the following service cases
+        _mock_service_get_all_by_topic.side_effect = [empty_service,
+                                                      host_not_match,
+                                                      az_not_match,
+                                                      disabled_service,
+                                                      dead_service,
+                                                      multi_services]
 
         volume_id = utils.create_volume(self.context, size=2,
                                         host=test_host)['id']
         volume = self.volume_api.get(context.get_admin_context(), volume_id)
 
         #test empty service
-        self.stubs.Set(cinder.db, 'service_get_all_by_topic', empty_service)
         self.assertEqual(self.backup_api._is_backup_service_enabled(volume,
                                                                     test_host),
                          False)
 
         #test host not match service
-        self.stubs.Set(cinder.db, 'service_get_all_by_topic', host_not_match)
         self.assertEqual(self.backup_api._is_backup_service_enabled(volume,
                                                                     test_host),
                          False)
 
         #test az not match service
-        self.stubs.Set(cinder.db, 'service_get_all_by_topic', az_not_match)
         self.assertEqual(self.backup_api._is_backup_service_enabled(volume,
                                                                     test_host),
                          False)
 
         #test disabled service
-        self.stubs.Set(cinder.db, 'service_get_all_by_topic', disabled_service)
         self.assertEqual(self.backup_api._is_backup_service_enabled(volume,
                                                                     test_host),
                          False)
 
         #test dead service
-        self.stubs.Set(cinder.db, 'service_get_all_by_topic', dead_service)
         self.assertEqual(self.backup_api._is_backup_service_enabled(volume,
                                                                     test_host),
                          False)
 
         #test multi services and the last service matches
-        self.stubs.Set(cinder.db, 'service_get_all_by_topic', multi_services)
         self.assertEqual(self.backup_api._is_backup_service_enabled(volume,
                                                                     test_host),
                          True)
@@ -708,16 +707,17 @@ class BackupsAPITestCase(test.TestCase):
         self.assertEqual(res_dict['badRequest']['message'],
                          'Incorrect request body format')
 
-    def test_restore_backup_volume_id_unspecified(self):
+    @mock.patch('cinder.volume.API.create')
+    def test_restore_backup_volume_id_unspecified(self,
+                                                  _mock_volume_api_create):
 
         # intercept volume creation to ensure created volume
         # has status of available
-        def fake_volume_api_create(cls, context, size, name, description):
+        def fake_volume_api_create(context, size, name, description):
             volume_id = utils.create_volume(self.context, size=size)['id']
             return db.volume_get(context, volume_id)
 
-        self.stubs.Set(cinder.volume.API, 'create',
-                       fake_volume_api_create)
+        _mock_volume_api_create.side_effect = fake_volume_api_create
 
         backup_id = self._create_backup(size=5, status='available')
 
@@ -733,16 +733,13 @@ class BackupsAPITestCase(test.TestCase):
         self.assertEqual(res.status_int, 202)
         self.assertEqual(res_dict['restore']['backup_id'], backup_id)
 
-    def test_restore_backup_with_InvalidInput(self):
+    @mock.patch('cinder.backup.API.restore')
+    def test_restore_backup_with_InvalidInput(self,
+                                              _mock_volume_api_restore):
 
-        def fake_backup_api_restore_throwing_InvalidInput(cls, context,
-                                                          backup_id,
-                                                          volume_id):
-            msg = _("Invalid input")
-            raise exception.InvalidInput(reason=msg)
-
-        self.stubs.Set(cinder.backup.API, 'restore',
-                       fake_backup_api_restore_throwing_InvalidInput)
+        msg = _("Invalid input")
+        _mock_volume_api_restore.side_effect = \
+            exception.InvalidInput(reason=msg)
 
         backup_id = self._create_backup(status='available')
         # need to create the volume referenced below first
@@ -846,18 +843,15 @@ class BackupsAPITestCase(test.TestCase):
 
         db.backup_destroy(context.get_admin_context(), backup_id)
 
-    def test_restore_backup_with_VolumeSizeExceedsAvailableQuota(self):
-
-        def fake_backup_api_restore_throwing_VolumeSizeExceedsAvailableQuota(
-                cls, context, backup_id, volume_id):
-            raise exception.VolumeSizeExceedsAvailableQuota(requested='2',
-                                                            consumed='2',
-                                                            quota='3')
+    @mock.patch('cinder.backup.API.restore')
+    def test_restore_backup_with_VolumeSizeExceedsAvailableQuota(
+            self,
+            _mock_backup_restore):
 
-        self.stubs.Set(
-            cinder.backup.API,
-            'restore',
-            fake_backup_api_restore_throwing_VolumeSizeExceedsAvailableQuota)
+        _mock_backup_restore.side_effect = \
+            exception.VolumeSizeExceedsAvailableQuota(requested='2',
+                                                      consumed='2',
+                                                      quota='3')
 
         backup_id = self._create_backup(status='available')
         # need to create the volume referenced below first
@@ -880,16 +874,12 @@ class BackupsAPITestCase(test.TestCase):
                          'Gigabytes quota. Requested 2G, quota is 3G and '
                          '2G has been consumed.')
 
-    def test_restore_backup_with_VolumeLimitExceeded(self):
+    @mock.patch('cinder.backup.API.restore')
+    def test_restore_backup_with_VolumeLimitExceeded(self,
+                                                     _mock_backup_restore):
 
-        def fake_backup_api_restore_throwing_VolumeLimitExceeded(cls,
-                                                                 context,
-                                                                 backup_id,
-                                                                 volume_id):
-            raise exception.VolumeLimitExceeded(allowed=1)
-
-        self.stubs.Set(cinder.backup.API, 'restore',
-                       fake_backup_api_restore_throwing_VolumeLimitExceeded)
+        _mock_backup_restore.side_effect = \
+            exception.VolumeLimitExceeded(allowed=1)
 
         backup_id = self._create_backup(status='available')
         # need to create the volume referenced below first
@@ -956,3 +946,315 @@ class BackupsAPITestCase(test.TestCase):
 
         db.volume_destroy(context.get_admin_context(), volume_id)
         db.backup_destroy(context.get_admin_context(), backup_id)
+
+    def test_export_record_as_non_admin(self):
+        backup_id = self._create_backup(status='available', size=10)
+        req = webob.Request.blank('/v2/fake/backups/%s/export_record' %
+                                  backup_id)
+        req.method = 'GET'
+        req.headers['content-type'] = 'application/json'
+
+        res = req.get_response(fakes.wsgi_app())
+        # request is not authorized
+        self.assertEqual(res.status_int, 403)
+
+    @mock.patch('cinder.backup.rpcapi.BackupAPI.export_record')
+    def test_export_backup_record_id_specified_json(self,
+                                                    _mock_export_record_rpc):
+        backup_id = self._create_backup(status='available', size=10)
+        ctx = context.RequestContext('admin', 'fake', is_admin=True)
+        backup_service = 'fake'
+        backup_url = 'fake'
+        _mock_export_record_rpc.return_value = \
+            {'backup_service': backup_service,
+             'backup_url': backup_url}
+        req = webob.Request.blank('/v2/fake/backups/%s/export_record' %
+                                  backup_id)
+        req.method = 'GET'
+        req.headers['content-type'] = 'application/json'
+
+        res = req.get_response(fakes.wsgi_app(fake_auth_context=ctx))
+        res_dict = json.loads(res.body)
+        # verify that request is successful
+        self.assertEqual(res.status_int, 200)
+        self.assertEqual(res_dict['backup-record']['backup_service'],
+                         backup_service)
+        self.assertEqual(res_dict['backup-record']['backup_url'],
+                         backup_url)
+        db.backup_destroy(context.get_admin_context(), backup_id)
+
+    @mock.patch('cinder.backup.rpcapi.BackupAPI.export_record')
+    def test_export_record_backup_id_specified_xml(self,
+                                                   _mock_export_record_rpc):
+        backup_id = self._create_backup(status='available', size=10)
+        ctx = context.RequestContext('admin', 'fake', is_admin=True)
+        backup_service = 'fake'
+        backup_url = 'fake'
+        _mock_export_record_rpc.return_value = \
+            {'backup_service': backup_service,
+             'backup_url': backup_url}
+        req = webob.Request.blank('/v2/fake/backups/%s/export_record' %
+                                  backup_id)
+        req.method = 'GET'
+        req.headers['Content-Type'] = 'application/xml'
+        req.headers['Accept'] = 'application/xml'
+        res = req.get_response(fakes.wsgi_app(fake_auth_context=ctx))
+        self.assertEqual(res.status_int, 200)
+        dom = minidom.parseString(res.body)
+        export = dom.getElementsByTagName('backup-record')
+        self.assertEqual(export.item(0).getAttribute('backup_service'),
+                         backup_service)
+        self.assertEqual(export.item(0).getAttribute('backup_url'),
+                         backup_url)
+
+        #db.backup_destroy(context.get_admin_context(), backup_id)
+
+    def test_export_record_with_bad_backup_id(self):
+
+        ctx = context.RequestContext('admin', 'fake', is_admin=True)
+        backup_id = 'bad_id'
+        req = webob.Request.blank('/v2/fake/backups/%s/export_record' %
+                                  backup_id)
+        req.method = 'GET'
+        req.headers['content-type'] = 'application/json'
+
+        res = req.get_response(fakes.wsgi_app(fake_auth_context=ctx))
+        res_dict = json.loads(res.body)
+        self.assertEqual(res.status_int, 404)
+        self.assertEqual(res_dict['itemNotFound']['code'], 404)
+        self.assertEqual(res_dict['itemNotFound']['message'],
+                         'Backup %s could not be found.' % backup_id)
+
+    def test_export_record_for_unavailable_backup(self):
+
+        backup_id = self._create_backup(status='restoring')
+        ctx = context.RequestContext('admin', 'fake', is_admin=True)
+        req = webob.Request.blank('/v2/fake/backups/%s/export_record' %
+                                  backup_id)
+        req.method = 'GET'
+        req.headers['content-type'] = 'application/json'
+
+        res = req.get_response(fakes.wsgi_app(fake_auth_context=ctx))
+        res_dict = json.loads(res.body)
+        self.assertEqual(res.status_int, 400)
+        self.assertEqual(res_dict['badRequest']['code'], 400)
+        self.assertEqual(res_dict['badRequest']['message'],
+                         'Invalid backup: Backup status must be available '
+                         'and not restoring.')
+        db.backup_destroy(context.get_admin_context(), backup_id)
+
+    @mock.patch('cinder.backup.rpcapi.BackupAPI.export_record')
+    def test_export_record_with_unavailable_service(self,
+                                                    _mock_export_record_rpc):
+        msg = 'fake unavailable service'
+        _mock_export_record_rpc.side_effect = \
+            exception.InvalidBackup(reason=msg)
+        backup_id = self._create_backup(status='available')
+        ctx = context.RequestContext('admin', 'fake', is_admin=True)
+        req = webob.Request.blank('/v2/fake/backups/%s/export_record' %
+                                  backup_id)
+        req.method = 'GET'
+        req.headers['content-type'] = 'application/json'
+
+        res = req.get_response(fakes.wsgi_app(fake_auth_context=ctx))
+        res_dict = json.loads(res.body)
+
+        self.assertEqual(res.status_int, 400)
+        self.assertEqual(res_dict['badRequest']['code'], 400)
+        self.assertEqual(res_dict['badRequest']['message'],
+                         'Invalid backup: %s' % msg)
+        db.backup_destroy(context.get_admin_context(), backup_id)
+
+    def test_import_record_as_non_admin(self):
+        backup_service = 'fake'
+        backup_url = 'fake'
+        req = webob.Request.blank('/v2/fake/backups/import_record')
+        body = {'backup-record': {'backup_service': backup_service,
+                                  'backup_url': backup_url}}
+        req.body = json.dumps(body)
+        req.method = 'POST'
+        req.headers['content-type'] = 'application/json'
+
+        res = req.get_response(fakes.wsgi_app())
+        # request is not authorized
+        self.assertEqual(res.status_int, 403)
+
+    @mock.patch('cinder.backup.api.API._list_backup_services')
+    @mock.patch('cinder.backup.rpcapi.BackupAPI.import_record')
+    def test_import_record_volume_id_specified_json(self,
+                                                    _mock_import_record_rpc,
+                                                    _mock_list_services):
+        ctx = context.RequestContext('admin', 'fake', is_admin=True)
+        backup_service = 'fake'
+        backup_url = 'fake'
+        _mock_import_record_rpc.return_value = \
+            {'display_name': 'fake',
+             'display_description': 'fake',
+             'container': 'fake',
+             'size': 1,
+             'service_metadata': 'fake',
+             'service': 'fake',
+             'object_count': 1,
+             'status': 'available',
+             'availability_zone': 'fake'}
+        _mock_list_services.return_value = ['fake']
+
+        req = webob.Request.blank('/v2/fake/backups/import_record')
+        body = {'backup-record': {'backup_service': backup_service,
+                                  'backup_url': backup_url}}
+        req.body = json.dumps(body)
+        req.method = 'POST'
+        req.headers['content-type'] = 'application/json'
+
+        res = req.get_response(fakes.wsgi_app(fake_auth_context=ctx))
+        res_dict = json.loads(res.body)
+        # verify that request is successful
+        self.assertEqual(res.status_int, 201)
+        self.assertTrue('id' in res_dict['backup'])
+
+    @mock.patch('cinder.backup.api.API._list_backup_services')
+    @mock.patch('cinder.backup.rpcapi.BackupAPI.import_record')
+    def test_import_record_volume_id_specified_xml(self,
+                                                   _mock_import_record_rpc,
+                                                   _mock_list_services):
+        ctx = context.RequestContext('admin', 'fake', is_admin=True)
+        backup_service = 'fake'
+        backup_url = 'fake'
+        _mock_import_record_rpc.return_value = \
+            {'display_name': 'fake',
+             'display_description': 'fake',
+             'container': 'fake',
+             'size': 1,
+             'service_metadata': 'fake',
+             'service': 'fake',
+             'object_count': 1,
+             'status': 'available',
+             'availability_zone': 'fake'}
+        _mock_list_services.return_value = ['fake']
+
+        req = webob.Request.blank('/v2/fake/backups/import_record')
+        req.body = ('<backup-record backup_service="%(backup_service)s" '
+                    'backup_url="%(backup_url)s"/>') \
+            % {'backup_url': backup_url,
+               'backup_service': backup_service}
+
+        req.method = 'POST'
+        req.headers['Content-Type'] = 'application/xml'
+        req.headers['Accept'] = 'application/xml'
+        res = req.get_response(fakes.wsgi_app(fake_auth_context=ctx))
+
+        self.assertEqual(res.status_int, 201)
+        dom = minidom.parseString(res.body)
+        backup = dom.getElementsByTagName('backup')
+        self.assertTrue(backup.item(0).hasAttribute('id'))
+
+    @mock.patch('cinder.backup.api.API._list_backup_services')
+    def test_import_record_with_no_backup_services(self,
+                                                   _mock_list_services):
+        ctx = context.RequestContext('admin', 'fake', is_admin=True)
+        backup_service = 'fake'
+        backup_url = 'fake'
+        _mock_list_services.return_value = []
+
+        req = webob.Request.blank('/v2/fake/backups/import_record')
+        body = {'backup-record': {'backup_service': backup_service,
+                                  'backup_url': backup_url}}
+        req.body = json.dumps(body)
+        req.method = 'POST'
+        req.headers['content-type'] = 'application/json'
+
+        res = req.get_response(fakes.wsgi_app(fake_auth_context=ctx))
+        res_dict = json.loads(res.body)
+        self.assertEqual(res.status_int, 500)
+        self.assertEqual(res_dict['computeFault']['code'], 500)
+        self.assertEqual(res_dict['computeFault']['message'],
+                         'Service %s could not be found.'
+                         % backup_service)
+
+    @mock.patch('cinder.backup.api.API._list_backup_services')
+    @mock.patch('cinder.backup.rpcapi.BackupAPI.import_record')
+    def test_import_backup_with_missing_backup_services(self,
+                                                        _mock_import_record,
+                                                        _mock_list_services):
+        ctx = context.RequestContext('admin', 'fake', is_admin=True)
+        backup_service = 'fake'
+        backup_url = 'fake'
+        _mock_list_services.return_value = ['no-match1', 'no-match2']
+        _mock_import_record.side_effect = \
+            exception.ServiceNotFound(service_id='fake')
+        req = webob.Request.blank('/v2/fake/backups/import_record')
+        body = {'backup-record': {'backup_service': backup_service,
+                                  'backup_url': backup_url}}
+        req.body = json.dumps(body)
+        req.method = 'POST'
+        req.headers['content-type'] = 'application/json'
+
+        res = req.get_response(fakes.wsgi_app(fake_auth_context=ctx))
+        res_dict = json.loads(res.body)
+        self.assertEqual(res.status_int, 500)
+        self.assertEqual(res_dict['computeFault']['code'], 500)
+        self.assertEqual(res_dict['computeFault']['message'],
+                         'Service %s could not be found.'
+                         % backup_service)
+
+    def test_import_record_with_missing_body_elements(self):
+        ctx = context.RequestContext('admin', 'fake', is_admin=True)
+        backup_service = 'fake'
+        backup_url = 'fake'
+
+        #test with no backup_service
+        req = webob.Request.blank('/v2/fake/backups/import_record')
+        body = {'backup-record': {'backup_url': backup_url}}
+        req.body = json.dumps(body)
+        req.method = 'POST'
+        req.headers['content-type'] = 'application/json'
+        res = req.get_response(fakes.wsgi_app(fake_auth_context=ctx))
+        res_dict = json.loads(res.body)
+        self.assertEqual(res.status_int, 400)
+        self.assertEqual(res_dict['badRequest']['code'], 400)
+        self.assertEqual(res_dict['badRequest']['message'],
+                         'Incorrect request body format.')
+
+        #test with no backup_url
+        req = webob.Request.blank('/v2/fake/backups/import_record')
+        body = {'backup-record': {'backup_service': backup_service}}
+        req.body = json.dumps(body)
+        req.method = 'POST'
+        req.headers['content-type'] = 'application/json'
+
+        res = req.get_response(fakes.wsgi_app(fake_auth_context=ctx))
+        res_dict = json.loads(res.body)
+        self.assertEqual(res.status_int, 400)
+        self.assertEqual(res_dict['badRequest']['code'], 400)
+        self.assertEqual(res_dict['badRequest']['message'],
+                         'Incorrect request body format.')
+
+        #test with no backup_url and backup_url
+        req = webob.Request.blank('/v2/fake/backups/import_record')
+        body = {'backup-record': {}}
+        req.body = json.dumps(body)
+        req.method = 'POST'
+        req.headers['content-type'] = 'application/json'
+
+        res = req.get_response(fakes.wsgi_app(fake_auth_context=ctx))
+        res_dict = json.loads(res.body)
+        self.assertEqual(res.status_int, 400)
+        self.assertEqual(res_dict['badRequest']['code'], 400)
+        self.assertEqual(res_dict['badRequest']['message'],
+                         'Incorrect request body format.')
+
+    def test_import_record_with_no_body(self):
+        ctx = context.RequestContext('admin', 'fake', is_admin=True)
+
+        req = webob.Request.blank('/v2/fake/backups/import_record')
+        req.body = json.dumps(None)
+        req.method = 'POST'
+        req.headers['content-type'] = 'application/json'
+
+        res = req.get_response(fakes.wsgi_app(fake_auth_context=ctx))
+        res_dict = json.loads(res.body)
+        # verify that request is successful
+        self.assertEqual(res.status_int, 400)
+        self.assertEqual(res_dict['badRequest']['code'], 400)
+        self.assertEqual(res_dict['badRequest']['message'],
+                         'Incorrect request body format.')
index 78ee5f1aef3ecd93d97309a28e9b9876497dc0d9..d8d76f6afa8dfdfa11a4a32b96fad4e3473129f0 100644 (file)
@@ -71,5 +71,8 @@
     "backup:delete": [],
     "backup:get": [],
     "backup:get_all": [],
-    "backup:restore": []
+    "backup:restore": [],
+    "backup:backup-import": [["rule:admin_api"]],
+    "backup:backup-export": [["rule:admin_api"]]
+
 }
index 310326bd0af32cc0ae459e84575072283511ee6c..bc219361aead5631c07235e3821afeafcdc3d513 100644 (file)
@@ -17,6 +17,7 @@ Tests for Backup code.
 
 """
 
+import mock
 import tempfile
 
 from oslo.config import cfg
@@ -59,7 +60,7 @@ class BackupTestCase(test.TestCase):
                                 display_description='this is a test backup',
                                 container='volumebackups',
                                 status='creating',
-                                size=0,
+                                size=1,
                                 object_count=0,
                                 project_id='fake'):
         """Create a backup entry in the DB.
@@ -101,6 +102,31 @@ class BackupTestCase(test.TestCase):
         vol['attach_status'] = 'detached'
         return db.volume_create(self.ctxt, vol)['id']
 
+    def _create_exported_record_entry(self, vol_size=1):
+        """Create backup metadata export entry."""
+        vol_id = self._create_volume_db_entry(status='available',
+                                              size=vol_size)
+        backup_id = self._create_backup_db_entry(status='available',
+                                                 volume_id=vol_id)
+
+        export = self.backup_mgr.export_record(self.ctxt, backup_id)
+        return export
+
+    def _create_export_record_db_entry(self,
+                                       volume_id='0000',
+                                       status='creating',
+                                       project_id='fake'):
+        """Create a backup entry in the DB.
+
+        Return the entry ID
+        """
+        backup = {}
+        backup['volume_id'] = volume_id
+        backup['user_id'] = 'fake'
+        backup['project_id'] = project_id
+        backup['status'] = status
+        return db.backup_create(self.ctxt, backup)['id']
+
     def test_init_host(self):
         """Make sure stuck volumes and backups are reset to correct
         states when backup_manager.init_host() is called
@@ -149,17 +175,13 @@ class BackupTestCase(test.TestCase):
                           self.ctxt,
                           backup_id)
 
-    def test_create_backup_with_error(self):
+    @mock.patch('%s.%s' % (CONF.volume_driver, 'backup_volume'))
+    def test_create_backup_with_error(self, _mock_volume_backup):
         """Test error handling when error occurs during backup creation."""
         vol_id = self._create_volume_db_entry(size=1)
         backup_id = self._create_backup_db_entry(volume_id=vol_id)
 
-        def fake_backup_volume(context, backup, backup_service):
-            raise FakeBackupException('fake')
-
-        self.stubs.Set(self.backup_mgr.driver, 'backup_volume',
-                       fake_backup_volume)
-
+        _mock_volume_backup.side_effect = FakeBackupException('fake')
         self.assertRaises(FakeBackupException,
                           self.backup_mgr.create_backup,
                           self.ctxt,
@@ -168,25 +190,22 @@ class BackupTestCase(test.TestCase):
         self.assertEqual(vol['status'], 'available')
         backup = db.backup_get(self.ctxt, backup_id)
         self.assertEqual(backup['status'], 'error')
+        self.assertTrue(_mock_volume_backup.called)
 
-    def test_create_backup(self):
+    @mock.patch('%s.%s' % (CONF.volume_driver, 'backup_volume'))
+    def test_create_backup(self, _mock_volume_backup):
         """Test normal backup creation."""
         vol_size = 1
         vol_id = self._create_volume_db_entry(size=vol_size)
         backup_id = self._create_backup_db_entry(volume_id=vol_id)
 
-        def fake_backup_volume(context, backup, backup_service):
-            pass
-
-        self.stubs.Set(self.backup_mgr.driver, 'backup_volume',
-                       fake_backup_volume)
-
         self.backup_mgr.create_backup(self.ctxt, backup_id)
         vol = db.volume_get(self.ctxt, vol_id)
         self.assertEqual(vol['status'], 'available')
         backup = db.backup_get(self.ctxt, backup_id)
         self.assertEqual(backup['status'], 'available')
         self.assertEqual(backup['size'], vol_size)
+        self.assertTrue(_mock_volume_backup.called)
 
     def test_restore_backup_with_bad_volume_status(self):
         """Test error handling when restoring a backup to a volume
@@ -220,19 +239,15 @@ class BackupTestCase(test.TestCase):
         backup = db.backup_get(self.ctxt, backup_id)
         self.assertEqual(backup['status'], 'error')
 
-    def test_restore_backup_with_driver_error(self):
+    @mock.patch('%s.%s' % (CONF.volume_driver, 'restore_backup'))
+    def test_restore_backup_with_driver_error(self, _mock_volume_restore):
         """Test error handling when an error occurs during backup restore."""
         vol_id = self._create_volume_db_entry(status='restoring-backup',
                                               size=1)
         backup_id = self._create_backup_db_entry(status='restoring',
                                                  volume_id=vol_id)
 
-        def fake_restore_backup(context, backup, volume, backup_service):
-            raise FakeBackupException('fake')
-
-        self.stubs.Set(self.backup_mgr.driver, 'restore_backup',
-                       fake_restore_backup)
-
+        _mock_volume_restore.side_effect = FakeBackupException('fake')
         self.assertRaises(FakeBackupException,
                           self.backup_mgr.restore_backup,
                           self.ctxt,
@@ -242,6 +257,7 @@ class BackupTestCase(test.TestCase):
         self.assertEqual(vol['status'], 'error_restoring')
         backup = db.backup_get(self.ctxt, backup_id)
         self.assertEqual(backup['status'], 'available')
+        self.assertTrue(_mock_volume_restore.called)
 
     def test_restore_backup_with_bad_service(self):
         """Test error handling when attempting a restore of a backup
@@ -252,12 +268,6 @@ class BackupTestCase(test.TestCase):
         backup_id = self._create_backup_db_entry(status='restoring',
                                                  volume_id=vol_id)
 
-        def fake_restore_backup(context, backup, volume, backup_service):
-            pass
-
-        self.stubs.Set(self.backup_mgr.driver, 'restore_backup',
-                       fake_restore_backup)
-
         service = 'cinder.tests.backup.bad_service'
         db.backup_update(self.ctxt, backup_id, {'service': service})
         self.assertRaises(exception.InvalidBackup,
@@ -270,7 +280,8 @@ class BackupTestCase(test.TestCase):
         backup = db.backup_get(self.ctxt, backup_id)
         self.assertEqual(backup['status'], 'available')
 
-    def test_restore_backup(self):
+    @mock.patch('%s.%s' % (CONF.volume_driver, 'restore_backup'))
+    def test_restore_backup(self, _mock_volume_restore):
         """Test normal backup restoration."""
         vol_size = 1
         vol_id = self._create_volume_db_entry(status='restoring-backup',
@@ -278,17 +289,12 @@ class BackupTestCase(test.TestCase):
         backup_id = self._create_backup_db_entry(status='restoring',
                                                  volume_id=vol_id)
 
-        def fake_restore_backup(context, backup, volume, backup_service):
-            pass
-
-        self.stubs.Set(self.backup_mgr.driver, 'restore_backup',
-                       fake_restore_backup)
-
         self.backup_mgr.restore_backup(self.ctxt, backup_id, vol_id)
         vol = db.volume_get(self.ctxt, vol_id)
         self.assertEqual(vol['status'], 'available')
         backup = db.backup_get(self.ctxt, backup_id)
         self.assertEqual(backup['status'], 'available')
+        self.assertTrue(_mock_volume_restore.called)
 
     def test_delete_backup_with_bad_backup_status(self):
         """Test error handling when deleting a backup with a backup
@@ -418,3 +424,176 @@ class BackupTestCase(test.TestCase):
         self.assertEqual('cinder.backup.drivers.swift',
                          backup_mgr.driver_name)
         setattr(cfg.CONF, 'backup_driver', old_setting)
+
+    def test_export_record_with_bad_service(self):
+        """Test error handling when attempting an export of a backup
+        record with a different service to that used to create the backup.
+        """
+        vol_id = self._create_volume_db_entry(size=1)
+        backup_id = self._create_backup_db_entry(status='available',
+                                                 volume_id=vol_id)
+        service = 'cinder.tests.backup.bad_service'
+        db.backup_update(self.ctxt, backup_id, {'service': service})
+        self.assertRaises(exception.InvalidBackup,
+                          self.backup_mgr.export_record,
+                          self.ctxt,
+                          backup_id)
+
+    def test_export_record_with_bad_backup_status(self):
+        """Test error handling when exporting a backup record with a backup
+        with a bad status.
+        """
+        vol_id = self._create_volume_db_entry(status='available',
+                                              size=1)
+        backup_id = self._create_backup_db_entry(status='error',
+                                                 volume_id=vol_id)
+        self.assertRaises(exception.InvalidBackup,
+                          self.backup_mgr.export_record,
+                          self.ctxt,
+                          backup_id)
+
+    def test_export_record(self):
+        """Test normal backup record export."""
+        vol_size = 1
+        vol_id = self._create_volume_db_entry(status='available',
+                                              size=vol_size)
+        backup_id = self._create_backup_db_entry(status='available',
+                                                 volume_id=vol_id)
+
+        export = self.backup_mgr.export_record(self.ctxt, backup_id)
+        self.assertEqual(export['backup_service'], CONF.backup_driver)
+        self.assertTrue('backup_url' in export)
+
+    def test_import_record_with_verify_not_implemented(self):
+        """Test normal backup record import.
+
+        Test the case when import succeeds for the case that the
+        driver does not support verify.
+        """
+        vol_size = 1
+        export = self._create_exported_record_entry(vol_size=vol_size)
+        imported_record = self._create_export_record_db_entry()
+        backup_hosts = []
+        backup_driver = self.backup_mgr.service.get_backup_driver(self.ctxt)
+        _mock_backup_verify_class = ('%s.%s.%s' %
+                                     (backup_driver.__module__,
+                                      backup_driver.__class__.__name__,
+                                      'verify'))
+        with mock.patch(_mock_backup_verify_class) as _mock_record_verify:
+            _mock_record_verify.side_effect = NotImplementedError()
+            self.backup_mgr.import_record(self.ctxt,
+                                          imported_record,
+                                          export['backup_service'],
+                                          export['backup_url'],
+                                          backup_hosts)
+        backup = db.backup_get(self.ctxt, imported_record)
+        self.assertEqual(backup['status'], 'available')
+        self.assertEqual(backup['size'], vol_size)
+
+    def test_import_record_with_verify(self):
+        """Test normal backup record import.
+
+        Test the case when import succeeds for the case that the
+        driver implements verify.
+        """
+        vol_size = 1
+        export = self._create_exported_record_entry(vol_size=vol_size)
+        imported_record = self._create_export_record_db_entry()
+        backup_hosts = []
+        backup_driver = self.backup_mgr.service.get_backup_driver(self.ctxt)
+        _mock_backup_verify_class = ('%s.%s.%s' %
+                                     (backup_driver.__module__,
+                                      backup_driver.__class__.__name__,
+                                      'verify'))
+        with mock.patch(_mock_backup_verify_class) as _mock_record_verify:
+            self.backup_mgr.import_record(self.ctxt,
+                                          imported_record,
+                                          export['backup_service'],
+                                          export['backup_url'],
+                                          backup_hosts)
+        backup = db.backup_get(self.ctxt, imported_record)
+        self.assertEqual(backup['status'], 'available')
+        self.assertEqual(backup['size'], vol_size)
+
+    def test_import_record_with_bad_service(self):
+        """Test error handling when attempting an import of a backup
+        record with a different service to that used to create the backup.
+        """
+        export = self._create_exported_record_entry()
+        export['backup_service'] = 'cinder.tests.backup.bad_service'
+        imported_record = self._create_export_record_db_entry()
+
+        #Test the case where the additional hosts list is empty
+        backup_hosts = []
+        self.assertRaises(exception.ServiceNotFound,
+                          self.backup_mgr.import_record,
+                          self.ctxt,
+                          imported_record,
+                          export['backup_service'],
+                          export['backup_url'],
+                          backup_hosts)
+
+        #Test that the import backup keeps calling other hosts to find a
+        #suitable host for the backup service
+        backup_hosts = ['fake1', 'fake2']
+        BackupAPI_import = 'cinder.backup.rpcapi.BackupAPI.import_record'
+        with mock.patch(BackupAPI_import) as _mock_backup_import:
+            self.backup_mgr.import_record(self.ctxt,
+                                          imported_record,
+                                          export['backup_service'],
+                                          export['backup_url'],
+                                          backup_hosts)
+            self.assertTrue(_mock_backup_import.called)
+
+    def test_import_record_with_invalid_backup(self):
+        """Test error handling when attempting an import of a backup
+        record where the backup driver returns an exception.
+        """
+        export = self._create_exported_record_entry()
+        backup_driver = self.backup_mgr.service.get_backup_driver(self.ctxt)
+        _mock_record_import_class = ('%s.%s.%s' %
+                                     (backup_driver.__module__,
+                                      backup_driver.__class__.__name__,
+                                      'import_record'))
+        imported_record = self._create_export_record_db_entry()
+        backup_hosts = []
+        with mock.patch(_mock_record_import_class) as _mock_record_import:
+            _mock_record_import.side_effect = FakeBackupException('fake')
+            self.assertRaises(exception.InvalidBackup,
+                              self.backup_mgr.import_record,
+                              self.ctxt,
+                              imported_record,
+                              export['backup_service'],
+                              export['backup_url'],
+                              backup_hosts)
+            self.assertTrue(_mock_record_import.called)
+        backup = db.backup_get(self.ctxt, imported_record)
+        self.assertEqual(backup['status'], 'error')
+
+    def test_import_record_with_verify_invalid_backup(self):
+        """Test error handling when attempting an import of a backup
+        record where the backup driver returns an exception.
+        """
+        vol_size = 1
+        export = self._create_exported_record_entry(vol_size=vol_size)
+        imported_record = self._create_export_record_db_entry()
+        backup_hosts = []
+        backup_driver = self.backup_mgr.service.get_backup_driver(self.ctxt)
+        _mock_backup_verify_class = ('%s.%s.%s' %
+                                     (backup_driver.__module__,
+                                      backup_driver.__class__.__name__,
+                                      'verify'))
+        with mock.patch(_mock_backup_verify_class) as _mock_record_verify:
+            _mock_record_verify.side_effect = \
+                exception.InvalidBackup(reason='fake')
+
+            self.assertRaises(exception.InvalidBackup,
+                              self.backup_mgr.import_record,
+                              self.ctxt,
+                              imported_record,
+                              export['backup_service'],
+                              export['backup_url'],
+                              backup_hosts)
+            self.assertTrue(_mock_record_verify.called)
+        backup = db.backup_get(self.ctxt, imported_record)
+        self.assertEqual(backup['status'], 'error')
index d32e7c3273b2ac776bee92987b9e0f543801465c..32a790c5b3935e4809807f4bf60518c9f7065a7e 100644 (file)
@@ -25,6 +25,14 @@ from cinder.openstack.common import jsonutils
 from cinder import test
 
 
+_backup_db_fields = ['id', 'user_id', 'project_id',
+                     'volume_id', 'host', 'availability_zone',
+                     'display_name', 'display_description',
+                     'container', 'status', 'fail_reason',
+                     'service_metadata', 'service', 'size',
+                     'object_count']
+
+
 class BackupBaseDriverTestCase(test.TestCase):
 
     def _create_volume_db_entry(self, id, size):
@@ -73,6 +81,26 @@ class BackupBaseDriverTestCase(test.TestCase):
         json_metadata = self.driver.get_metadata(self.volume_id)
         self.driver.put_metadata(self.volume_id, json_metadata)
 
+    def test_export_record(self):
+        export_string = self.driver.export_record(self.backup)
+        export_dict = jsonutils.loads(export_string.decode("base64"))
+        # Make sure we don't lose data when converting to string
+        for key in _backup_db_fields:
+            self.assertTrue(key in export_dict)
+            self.assertEqual(self.backup[key], export_dict[key])
+
+    def test_import_record(self):
+        export_string = self.driver.export_record(self.backup)
+        imported_backup = self.driver.import_record(export_string)
+        # Make sure we don't lose data when converting from string
+        for key in _backup_db_fields:
+            self.assertTrue(key in imported_backup)
+            self.assertEqual(imported_backup[key], self.backup[key])
+
+    def test_verify(self):
+        self.assertRaises(NotImplementedError,
+                          self.driver.verify, self.backup)
+
     def tearDown(self):
         super(BackupBaseDriverTestCase, self).tearDown()
 
index b149ecf0d789997f38eadabb3ccec28ee1feb700..9227c15bb28be3dc37a9a93864d74df1d59ac082 100644 (file)
@@ -52,6 +52,8 @@
     "backup:get": [],
     "backup:get_all": [],
     "backup:restore": [],
+    "backup:backup-import": [["rule:admin_api"]],
+    "backup:backup-export": [["rule:admin_api"]],
 
     "snapshot_extension:snapshot_actions:update_snapshot_status": []
 }