]> review.fuel-infra Code Review - openstack-build/cinder-build.git/commitdiff
Implement DB migration for volume transfer BP
authorOllie Leahy <oliver.leahy@hp.com>
Wed, 15 May 2013 10:05:49 +0000 (10:05 +0000)
committerOllie Leahy <oliver.leahy@hp.com>
Tue, 4 Jun 2013 14:38:28 +0000 (14:38 +0000)
This patch implements the backend for volume transfers as specified in the
Volume transfer blueprint.

Change-Id: Id18e57942c3908e2539f3b0845263d0fbdd198f5
implement bp: volume-transfer

14 files changed:
cinder/api/contrib/volume_transfer.py [new file with mode: 0644]
cinder/api/views/transfers.py [new file with mode: 0644]
cinder/db/api.py
cinder/db/sqlalchemy/api.py
cinder/db/sqlalchemy/migrate_repo/versions/010_add_transfers_table.py [new file with mode: 0644]
cinder/db/sqlalchemy/models.py
cinder/exception.py
cinder/flags.py
cinder/tests/api/contrib/test_volume_transfer.py [new file with mode: 0644]
cinder/tests/db/test_transfers.py [new file with mode: 0644]
cinder/tests/test_migrations.py
cinder/tests/test_volume_transfer.py [new file with mode: 0644]
cinder/transfer/__init__.py [new file with mode: 0644]
cinder/transfer/api.py [new file with mode: 0644]

diff --git a/cinder/api/contrib/volume_transfer.py b/cinder/api/contrib/volume_transfer.py
new file mode 100644 (file)
index 0000000..fd98af3
--- /dev/null
@@ -0,0 +1,244 @@
+# vim: tabstop=4 shiftwidth=4 softtabstop=4
+
+# Copyright 2011 OpenStack LLC.
+# All Rights Reserved.
+#
+#    Licensed under the Apache License, Version 2.0 (the "License"); you may
+#    not use this file except in compliance with the License. You may obtain
+#    a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#    Unless required by applicable law or agreed to in writing, software
+#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+#    License for the specific language governing permissions and limitations
+#    under the License.
+
+import webob
+from webob import exc
+from xml.dom import minidom
+
+from cinder.api import common
+from cinder.api import extensions
+from cinder.api.openstack import wsgi
+from cinder.api.views import transfers as transfer_view
+from cinder.api import xmlutil
+
+from cinder import exception
+from cinder.openstack.common import log as logging
+from cinder import transfer as transferAPI
+
+LOG = logging.getLogger(__name__)
+
+
+def make_transfer(elem):
+    elem.set('id')
+    elem.set('volume_id')
+    elem.set('created_at')
+    elem.set('name')
+    elem.set('auth_key')
+
+
+class TransferTemplate(xmlutil.TemplateBuilder):
+    def construct(self):
+        root = xmlutil.TemplateElement('transfer', selector='transfer')
+        make_transfer(root)
+        alias = Volume_transfer.alias
+        namespace = Volume_transfer.namespace
+        return xmlutil.MasterTemplate(root, 1, nsmap={alias: namespace})
+
+
+class TransfersTemplate(xmlutil.TemplateBuilder):
+    def construct(self):
+        root = xmlutil.TemplateElement('transfers')
+        elem = xmlutil.SubTemplateElement(root, 'transfer',
+                                          selector='transfers')
+        make_transfer(elem)
+        alias = Volume_transfer.alias
+        namespace = Volume_transfer.namespace
+        return xmlutil.MasterTemplate(root, 1, nsmap={alias: namespace})
+
+
+class CreateDeserializer(wsgi.MetadataXMLDeserializer):
+    def default(self, string):
+        dom = minidom.parseString(string)
+        transfer = self._extract_transfer(dom)
+        return {'body': {'transfer': transfer}}
+
+    def _extract_transfer(self, node):
+        transfer = {}
+        transfer_node = self.find_first_child_named(node, 'transfer')
+
+        attributes = ['volume_id', 'display_name']
+
+        for attr in attributes:
+            if transfer_node.getAttribute(attr):
+                transfer[attr] = transfer_node.getAttribute(attr)
+        return transfer
+
+
+class AcceptDeserializer(wsgi.MetadataXMLDeserializer):
+    def default(self, string):
+        dom = minidom.parseString(string)
+        transfer = self._extract_transfer(dom)
+        return {'body': {'accept': transfer}}
+
+    def _extract_transfer(self, node):
+        transfer = {}
+        transfer_node = self.find_first_child_named(node, 'accept')
+
+        attributes = ['auth_key']
+
+        for attr in attributes:
+            if transfer_node.getAttribute(attr):
+                transfer[attr] = transfer_node.getAttribute(attr)
+        return transfer
+
+
+class VolumeTransferController(wsgi.Controller):
+    """ The Volume Transfer API controller for the Openstack API."""
+
+    _view_builder_class = transfer_view.ViewBuilder
+
+    def __init__(self):
+        self.transfer_api = transferAPI.API()
+        super(VolumeTransferController, self).__init__()
+
+    @wsgi.serializers(xml=TransferTemplate)
+    def show(self, req, id):
+        """Return data about active transfers."""
+        context = req.environ['cinder.context']
+
+        try:
+            transfer = self.transfer_api.get(context, transfer_id=id)
+        except exception.TransferNotFound as error:
+            raise exc.HTTPNotFound(explanation=unicode(error))
+
+        return self._view_builder.detail(req, transfer)
+
+    @wsgi.serializers(xml=TransfersTemplate)
+    def index(self, req):
+        """Returns a summary list of transfers"""
+        return self._get_transfers(req, is_detail=False)
+
+    @wsgi.serializers(xml=TransfersTemplate)
+    def detail(self, req):
+        """Returns a detailed list of tranfers."""
+        return self._get_transfers(req, is_detail=True)
+
+    def _get_transfers(self, req, is_detail):
+        """Returns a list of transfers, transformed through view builder."""
+        context = req.environ['cinder.context']
+        LOG.debug(_('Listing volume transfers'))
+        transfers = self.transfer_api.get_all(context)
+        limited_list = common.limited(transfers, req)
+
+        if is_detail:
+            transfers = self._view_builder.detail_list(req, limited_list)
+        else:
+            transfers = self._view_builder.summary_list(req, limited_list)
+
+        return transfers
+
+    @wsgi.response(202)
+    @wsgi.serializers(xml=TransferTemplate)
+    @wsgi.deserializers(xml=CreateDeserializer)
+    def create(self, req, body):
+        """Create a new volume transfer."""
+        LOG.debug(_('Creating new volume transfer %s'), body)
+        if not self.is_valid_body(body, 'transfer'):
+            raise exc.HTTPBadRequest()
+
+        context = req.environ['cinder.context']
+
+        try:
+            transfer = body['transfer']
+            volume_id = transfer['volume_id']
+        except KeyError:
+            msg = _("Incorrect request body format")
+            raise exc.HTTPBadRequest(explanation=msg)
+
+        name = transfer.get('name', None)
+
+        LOG.audit(_("Creating transfer of volume %(volume_id)s"), locals(),
+                    context=context)
+
+        try:
+            new_transfer = self.transfer_api.create(context, volume_id, name)
+        except exception.InvalidVolume as error:
+            raise exc.HTTPBadRequest(explanation=unicode(error))
+        except exception.VolumeNotFound as error:
+            raise exc.HTTPNotFound(explanation=unicode(error))
+
+        transfer = self._view_builder.create(req,
+                                             dict(new_transfer.iteritems()))
+        return transfer
+
+    @wsgi.response(202)
+    @wsgi.serializers(xml=TransferTemplate)
+    @wsgi.deserializers(xml=AcceptDeserializer)
+    def accept(self, req, id, body):
+        """Accept a new volume transfer."""
+        transfer_id = id
+        LOG.debug(_('Accepting volume transfer %s'), transfer_id)
+        if not self.is_valid_body(body, 'accept'):
+            raise exc.HTTPBadRequest()
+
+        context = req.environ['cinder.context']
+
+        try:
+            accept = body['accept']
+            auth_key = accept['auth_key']
+        except KeyError:
+            msg = _("Incorrect request body format")
+            raise exc.HTTPBadRequest(explanation=msg)
+
+        LOG.audit(_("Accepting transfer %(transfer_id)s"), locals(),
+                    context=context)
+
+        try:
+            accepted_transfer = self.transfer_api.accept(context, transfer_id,
+                                                         auth_key)
+        except exception.VolumeSizeExceedsAvailableQuota as error:
+            raise exc.HTTPRequestEntityTooLarge(
+                explanation=error.message, headers={'Retry-After': 0})
+        except exception.InvalidVolume as error:
+            raise exc.HTTPBadRequest(explanation=unicode(error))
+
+        transfer = self._view_builder.summary(req,
+              dict(accepted_transfer.iteritems()))
+        return transfer
+
+    def delete(self, req, id):
+        """Delete a transfer."""
+        context = req.environ['cinder.context']
+
+        LOG.audit(_("Delete transfer with id: %s"), id, context=context)
+
+        try:
+            self.transfer_api.delete(context, transfer_id=id)
+        except exception.TransferNotFound as error:
+            raise exc.HTTPNotFound(explanation=unicode(error))
+        return webob.Response(status_int=202)
+
+
+class Volume_transfer(extensions.ExtensionDescriptor):
+    """Volume transfer management support"""
+
+    name = "VolumeTransfer"
+    alias = "os-volume-transfer"
+    namespace = "http://docs.openstack.org/volume/ext/volume-transfer/" + \
+                "api/v1.1"
+    updated = "2013-05-29T00:00:00+00:00"
+
+    def get_resources(self):
+        resources = []
+
+        res = extensions.ResourceExtension(Volume_transfer.alias,
+                                           VolumeTransferController(),
+                                           collection_actions={'detail':
+                                                               'GET'},
+                                           member_actions={'accept': 'POST'})
+        resources.append(res)
+        return resources
diff --git a/cinder/api/views/transfers.py b/cinder/api/views/transfers.py
new file mode 100644 (file)
index 0000000..29a5dc1
--- /dev/null
@@ -0,0 +1,89 @@
+# Copyright (C) 2012 Hewlett-Packard Development Company, L.P.
+# All Rights Reserved.
+#
+#    Licensed under the Apache License, Version 2.0 (the "License"); you may
+#    not use this file except in compliance with the License. You may obtain
+#    a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#    Unless required by applicable law or agreed to in writing, software
+#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+#    License for the specific language governing permissions and limitations
+#    under the License.
+
+from cinder.api import common
+from cinder.openstack.common import log as logging
+
+
+LOG = logging.getLogger(__name__)
+
+
+class ViewBuilder(common.ViewBuilder):
+    """Model transfer API responses as a python dictionary."""
+
+    _collection_name = "transfers"
+
+    def __init__(self):
+        """Initialize view builder."""
+        super(ViewBuilder, self).__init__()
+
+    def summary_list(self, request, transfers):
+        """Show a list of transfers without many details."""
+        return self._list_view(self.summary, request, transfers)
+
+    def detail_list(self, request, transfers):
+        """Detailed view of a list of transfers ."""
+        return self._list_view(self.detail, request, transfers)
+
+    def summary(self, request, transfer):
+        """Generic, non-detailed view of a transfer."""
+        return {
+            'transfer': {
+                'id': transfer['id'],
+                'volume_id': transfer.get('volume_id'),
+                'name': transfer['display_name'],
+                'links': self._get_links(request,
+                                         transfer['id']),
+            },
+        }
+
+    def detail(self, request, transfer):
+        """Detailed view of a single transfer."""
+        return {
+            'transfer': {
+                'id': transfer.get('id'),
+                'created_at': transfer.get('created_at'),
+                'name': transfer.get('display_name'),
+                'volume_id': transfer.get('volume_id'),
+                'links': self._get_links(request, transfer['id'])
+            }
+        }
+
+    def create(self, request, transfer):
+        """Detailed view of a single transfer when created."""
+        return {
+            'transfer': {
+                'id': transfer.get('id'),
+                'created_at': transfer.get('created_at'),
+                'name': transfer.get('display_name'),
+                'volume_id': transfer.get('volume_id'),
+                'auth_key': transfer.get('auth_key'),
+                'links': self._get_links(request, transfer['id'])
+            }
+        }
+
+    def _list_view(self, func, request, transfers):
+        """Provide a view for a list of transfers."""
+        transfers_list = [func(request, transfer)['transfer'] for transfer in
+                          transfers]
+        transfers_links = self._get_collection_links(request,
+                                                   transfers,
+                                                   self._collection_name)
+        transfers_dict = dict(transfers=transfers_list)
+
+        if transfers_links:
+            transfers_dict['transfers_links'] = transfers_links
+
+        return transfers_dict
index 189721e66e84d0bcb90dcd4f4af8dad0606565e4..efc1e35305bf2fa23fdb47b176b533301a951c19 100644 (file)
@@ -757,3 +757,36 @@ def backup_update(context, backup_id, values):
 def backup_destroy(context, backup_id):
     """Destroy the backup or raise if it does not exist."""
     return IMPL.backup_destroy(context, backup_id)
+
+
+###################
+
+
+def transfer_get(context, transfer_id):
+    """Get a volume transfer record or raise if it does not exist."""
+    return IMPL.transfer_get(context, transfer_id)
+
+
+def transfer_get_all(context):
+    """Get all volume transfer records."""
+    return IMPL.transfer_get_all(context)
+
+
+def transfer_get_all_by_project(context, project_id):
+    """Get all volume transfer records for specified project."""
+    return IMPL.transfer_get_all_by_project(context, project_id)
+
+
+def transfer_create(context, values):
+    """Create an entry in the transfers table."""
+    return IMPL.transfer_create(context, values)
+
+
+def transfer_destroy(context, transfer_id):
+    """Destroy a record in the volume transfer table."""
+    return IMPL.transfer_destroy(context, transfer_id)
+
+
+def transfer_accept(context, transfer_id, user_id, project_id):
+    """Accept a volume transfer."""
+    return IMPL.transfer_accept(context, transfer_id, user_id, project_id)
index 5e029fe87d645fde8e7a5f4f4ad6b6917ca9588b..3ee5ac3bf9c55f8fc736e5fc35a34174fa8ba475 100644 (file)
@@ -2020,3 +2020,135 @@ def backup_destroy(context, backup_id):
                     'deleted': True,
                     'deleted_at': timeutils.utcnow(),
                     'updated_at': literal_column('updated_at')})
+
+
+###############################
+
+
+@require_context
+def transfer_get(context, transfer_id, session=None):
+    query = model_query(context, models.Transfer,
+                         session=session).\
+        filter_by(id=transfer_id)
+
+    if not is_admin_context(context):
+        volume = models.Volume
+        query = query.options(joinedload('volume')).\
+            filter(volume.project_id == context.project_id)
+
+    result = query.first()
+
+    if not result:
+        raise exception.TransferNotFound(transfer_id=transfer_id)
+
+    return result
+
+
+def _translate_transfers(transfers):
+    results = []
+    for transfer in transfers:
+        r = {}
+        r['id'] = transfer['id']
+        r['volume_id'] = transfer['volume_id']
+        r['display_name'] = transfer['display_name']
+        r['created_at'] = transfer['created_at']
+        r['deleted'] = transfer['deleted']
+        results.append(r)
+    return results
+
+
+@require_admin_context
+def transfer_get_all(context):
+    results = model_query(context, models.Transfer).all()
+    return _translate_transfers(results)
+
+
+@require_context
+def transfer_get_all_by_project(context, project_id):
+    authorize_project_context(context, project_id)
+
+    volume = models.Volume
+    query = model_query(context, models.Transfer).\
+            options(joinedload('volume')).\
+            filter(volume.project_id == project_id)
+    results = query.all()
+    return _translate_transfers(results)
+
+
+@require_context
+def transfer_create(context, values):
+    transfer = models.Transfer()
+    if not values.get('id'):
+        values['id'] = str(uuid.uuid4())
+    session = get_session()
+    with session.begin():
+        volume_ref = volume_get(context,
+                                values['volume_id'],
+                                session=session)
+        if volume_ref['status'] != 'available':
+            msg = _('Volume must be available')
+            LOG.error(msg)
+            raise exception.InvalidVolume(reason=msg)
+        volume_ref['status'] = 'awaiting-transfer'
+        transfer.update(values)
+        transfer.save(session=session)
+        volume_ref.update(volume_ref)
+        volume_ref.save(session=session)
+    return transfer
+
+
+@require_context
+def transfer_destroy(context, transfer_id):
+    session = get_session()
+    with session.begin():
+        transfer_ref = transfer_get(context,
+                                    transfer_id,
+                                    session=session)
+        volume_ref = volume_get(context,
+                                transfer_ref['volume_id'],
+                                session=session)
+        # If the volume state is not 'awaiting-transfer' don't change it, but
+        # we can still mark the transfer record as deleted.
+        if volume_ref['status'] != 'awaiting-transfer':
+            msg = _('Volume in unexpected state %s, '
+                    'expected awaiting-transfer') % volume_ref['status']
+            LOG.error(msg)
+        else:
+            volume_ref['status'] = 'available'
+        volume_ref.update(volume_ref)
+        volume_ref.save(session=session)
+        session.query(models.Transfer).\
+            filter_by(id=transfer_id).\
+            update({'deleted': True,
+                    'deleted_at': timeutils.utcnow(),
+                    'updated_at': literal_column('updated_at')})
+
+
+@require_context
+def transfer_accept(context, transfer_id, user_id, project_id):
+    session = get_session()
+    with session.begin():
+        transfer_ref = transfer_get(context, transfer_id, session)
+        volume_id = transfer_ref['volume_id']
+        volume_ref = volume_get(context, volume_id, session=session)
+        if volume_ref['status'] != 'awaiting-transfer':
+            volume_status = volume_ref['status']
+            msg = _('Transfer %(transfer_id)s: Volume id %(volume_id)s in '
+                    'unexpected state %(status)s, expected '
+                    'awaiting-transfer') % {'transfer_id': transfer_id,
+                                            'volume_id': volume_ref['id'],
+                                            'status': volume_ref['status']}
+            LOG.error(msg)
+            raise exception.InvalidVolume(reason=msg)
+
+        volume_ref['status'] = 'available'
+        volume_ref['user_id'] = user_id
+        volume_ref['project_id'] = project_id
+        volume_ref['updated_at'] = literal_column('updated_at')
+        volume_ref.update(volume_ref)
+        volume_ref.save(session=session)
+        session.query(models.Transfer).\
+            filter_by(id=transfer_ref['id']).\
+            update({'deleted': True,
+                    'deleted_at': timeutils.utcnow(),
+                    'updated_at': literal_column('updated_at')})
diff --git a/cinder/db/sqlalchemy/migrate_repo/versions/010_add_transfers_table.py b/cinder/db/sqlalchemy/migrate_repo/versions/010_add_transfers_table.py
new file mode 100644 (file)
index 0000000..3eae5df
--- /dev/null
@@ -0,0 +1,71 @@
+# vim: tabstop=4 shiftwidth=4 softtabstop=4
+
+#    Licensed under the Apache License, Version 2.0 (the "License"); you may
+#    not use this file except in compliance with the License. You may obtain
+#    a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#    Unless required by applicable law or agreed to in writing, software
+#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+#    License for the specific language governing permissions and limitations
+#    under the License.
+
+from sqlalchemy import Boolean, Column, DateTime
+from sqlalchemy import MetaData, String, Table, ForeignKey
+
+from cinder.openstack.common import log as logging
+
+LOG = logging.getLogger(__name__)
+
+
+def upgrade(migrate_engine):
+    meta = MetaData()
+    meta.bind = migrate_engine
+
+    volumes = Table('volumes', meta, autoload=True)
+
+    # New table
+    transfers = Table(
+        'transfers', meta,
+        Column('created_at', DateTime(timezone=False)),
+        Column('updated_at', DateTime(timezone=False)),
+        Column('deleted_at', DateTime(timezone=False)),
+        Column('deleted', Boolean),
+        Column('id', String(36), primary_key=True, nullable=False),
+        Column('volume_id', String(length=36), ForeignKey('volumes.id'),
+               nullable=False),
+        Column('display_name', String(length=255,
+                                      convert_unicode=True,
+                                      unicode_error=None,
+                                      _warn_on_bytestring=False)),
+        Column('salt', String(length=255,
+                              convert_unicode=True,
+                              unicode_error=None,
+                              _warn_on_bytestring=False)),
+        Column('crypt_hash', String(length=255,
+                                    convert_unicode=True,
+                                    unicode_error=None,
+                                    _warn_on_bytestring=False)),
+        Column('expires_at', DateTime(timezone=False)),
+        mysql_engine='InnoDB'
+    )
+
+    try:
+        transfers.create()
+    except Exception:
+        LOG.error(_("Table |%s| not created!"), repr(transfers))
+        raise
+
+
+def downgrade(migrate_engine):
+    meta = MetaData()
+    meta.bind = migrate_engine
+    transfers = Table('transfers',
+                      meta,
+                      autoload=True)
+    try:
+        transfers.drop()
+    except Exception:
+        LOG.error(_("transfers table not dropped"))
index 8d33336e518261fa847f33d844c974d3bb90bc38..d030147912c0dfb08ef4f8e1b3c6a556386ee3f5 100644 (file)
@@ -387,6 +387,22 @@ class Backup(BASE, CinderBase):
     object_count = Column(Integer)
 
 
+class Transfer(BASE, CinderBase):
+    """Represents a volume transfer request."""
+    __tablename__ = 'transfers'
+    id = Column(String(36), primary_key=True)
+    volume_id = Column(String(36), ForeignKey('volumes.id'))
+    display_name = Column(String(255))
+    salt = Column(String(255))
+    crypt_hash = Column(String(255))
+    expires_at = Column(DateTime)
+    volume = relationship(Volume, backref="transfer",
+                          foreign_keys=volume_id,
+                          primaryjoin='and_('
+                          'Transfer.volume_id == Volume.id,'
+                          'Transfer.deleted == False)')
+
+
 def register_models():
     """Register Models and create metadata.
 
@@ -404,6 +420,7 @@ def register_models():
               Volume,
               VolumeMetadata,
               SnapshotMetadata,
+              Transfer,
               VolumeTypeExtraSpecs,
               VolumeTypes,
               VolumeGlanceMetadata,
index a5241b47fd8e444599af36d645da3c0804d1a53d..6ccbfc9fc38d0bed340d054acd6200835aa4a7f3 100644 (file)
@@ -182,6 +182,10 @@ class InvalidParameterValue(Invalid):
     message = _("%(err)s")
 
 
+class InvalidAuthKey(Invalid):
+    message = _("Invalid auth key") + ": %(reason)s"
+
+
 class ServiceUnavailable(Invalid):
     message = _("Service is unavailable at this time.")
 
@@ -554,3 +558,7 @@ class InvalidBackup(Invalid):
 
 class SwiftConnectionFailed(CinderException):
     message = _("Connection to swift failed") + ": %(reason)s"
+
+
+class TransferNotFound(NotFound):
+    message = _("Transfer %(transfer_id)s could not be found.")
index 02e8d977e0a9e95e1404568c7970a7e00ae1f01b..35e4321335bf1b0620c72fc6760a76aa41cccfdc 100644 (file)
@@ -217,6 +217,9 @@ global_opts = [
                      'with its options'),
     cfg.BoolOpt('no_snapshot_gb_quota',
                 default=False,
-                help='Whether snapshots count against GigaByte quota'), ]
+                help='Whether snapshots count against GigaByte quota'),
+    cfg.StrOpt('transfer_api_class',
+               default='cinder.transfer.api.API',
+               help='The full class name of the volume transfer API class'), ]
 
 FLAGS.register_opts(global_opts)
diff --git a/cinder/tests/api/contrib/test_volume_transfer.py b/cinder/tests/api/contrib/test_volume_transfer.py
new file mode 100644 (file)
index 0000000..fc8f37b
--- /dev/null
@@ -0,0 +1,572 @@
+# Copyright (C) 2012 Hewlett-Packard Development Company, L.P.
+# All Rights Reserved.
+#
+#    Licensed under the Apache License, Version 2.0 (the "License"); you may
+#    not use this file except in compliance with the License. You may obtain
+#    a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#    Unless required by applicable law or agreed to in writing, software
+#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+#    License for the specific language governing permissions and limitations
+#    under the License.
+
+"""
+Tests for volume transfer code.
+"""
+
+import json
+from xml.dom import minidom
+
+import webob
+
+from cinder import context
+from cinder import db
+from cinder import exception
+from cinder.openstack.common import log as logging
+from cinder import test
+from cinder.tests.api import fakes
+from cinder.transfer import API
+import cinder.volume
+
+
+LOG = logging.getLogger(__name__)
+volume_transfer_api = API()
+
+
+class VolumeTransferAPITestCase(test.TestCase):
+    """Test Case for transfers API."""
+
+    def setUp(self):
+        super(VolumeTransferAPITestCase, self).setUp()
+
+    def tearDown(self):
+        super(VolumeTransferAPITestCase, self).tearDown()
+
+    @staticmethod
+    def _create_transfer(volume_id=1,
+                         display_name='test_transfer'):
+        """Create a transfer object."""
+        return volume_transfer_api.create(context.get_admin_context(),
+                                          volume_id,
+                                          display_name)
+
+    @staticmethod
+    def _create_volume(display_name='test_volume',
+                       display_description='this is a test volume',
+                       status='available',
+                       size=1):
+        """Create a volume object."""
+        vol = {}
+        vol['size'] = size
+        vol['user_id'] = 'fake'
+        vol['project_id'] = 'fake'
+        vol['status'] = status
+        vol['display_name'] = display_name
+        vol['display_description'] = display_description
+        vol['attach_status'] = status
+        return db.volume_create(context.get_admin_context(), vol)['id']
+
+    def test_show_transfer(self):
+        volume_id = self._create_volume(size=5)
+        transfer = self._create_transfer(volume_id)
+        LOG.debug('Created transfer with id %s' % transfer)
+        req = webob.Request.blank('/v2/fake/os-volume-transfer/%s' %
+                                  transfer['id'])
+        req.method = 'GET'
+        req.headers['Content-Type'] = 'application/json'
+        res = req.get_response(fakes.wsgi_app())
+        res_dict = json.loads(res.body)
+        self.assertEqual(res.status_int, 200)
+        self.assertEqual(res_dict['transfer']['name'], 'test_transfer')
+        self.assertEqual(res_dict['transfer']['id'], transfer['id'])
+        self.assertEqual(res_dict['transfer']['volume_id'], volume_id)
+
+        db.transfer_destroy(context.get_admin_context(), transfer['id'])
+        db.volume_destroy(context.get_admin_context(), volume_id)
+
+    def test_show_transfer_xml_content_type(self):
+        volume_id = self._create_volume(size=5)
+        transfer = self._create_transfer(volume_id)
+        req = webob.Request.blank('/v2/fake/os-volume-transfer/%s' %
+                                  transfer['id'])
+        req.method = 'GET'
+        req.headers['Content-Type'] = 'application/xml'
+        req.headers['Accept'] = 'application/xml'
+        res = req.get_response(fakes.wsgi_app())
+        self.assertEqual(res.status_int, 200)
+        dom = minidom.parseString(res.body)
+        transfer_xml = dom.getElementsByTagName('transfer')
+        name = transfer_xml.item(0).getAttribute('name')
+        self.assertEquals(name.strip(), "test_transfer")
+
+        db.transfer_destroy(context.get_admin_context(), transfer['id'])
+        db.volume_destroy(context.get_admin_context(), volume_id)
+
+    def test_show_transfer_with_transfer_NotFound(self):
+        req = webob.Request.blank('/v2/fake/os-volume-transfer/1234')
+        req.method = 'GET'
+        req.headers['Content-Type'] = 'application/json'
+        res = req.get_response(fakes.wsgi_app())
+        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'],
+                         'Transfer 1234 could not be found.')
+
+    def test_list_transfers_json(self):
+        volume_id_1 = self._create_volume(size=5)
+        volume_id_2 = self._create_volume(size=5)
+        transfer1 = self._create_transfer(volume_id_1)
+        transfer2 = self._create_transfer(volume_id_2)
+
+        req = webob.Request.blank('/v2/fake/os-volume-transfer')
+        req.method = 'GET'
+        req.headers['Content-Type'] = 'application/json'
+        res = req.get_response(fakes.wsgi_app())
+        res_dict = json.loads(res.body)
+
+        self.assertEqual(res.status_int, 200)
+        self.assertEqual(len(res_dict['transfers'][0]), 4)
+        self.assertEqual(res_dict['transfers'][0]['id'], transfer1['id'])
+        self.assertEqual(res_dict['transfers'][0]['name'], 'test_transfer')
+        self.assertEqual(len(res_dict['transfers'][1]), 4)
+        self.assertEqual(res_dict['transfers'][1]['name'], 'test_transfer')
+
+        db.transfer_destroy(context.get_admin_context(), transfer2['id'])
+        db.transfer_destroy(context.get_admin_context(), transfer1['id'])
+        db.volume_destroy(context.get_admin_context(), volume_id_1)
+        db.volume_destroy(context.get_admin_context(), volume_id_2)
+
+    def test_list_transfers_xml(self):
+        volume_id_1 = self._create_volume(size=5)
+        volume_id_2 = self._create_volume(size=5)
+        transfer1 = self._create_transfer(volume_id_1)
+        transfer2 = self._create_transfer(volume_id_2)
+
+        req = webob.Request.blank('/v2/fake/os-volume-transfer')
+        req.method = 'GET'
+        req.headers['Content-Type'] = 'application/xml'
+        req.headers['Accept'] = 'application/xml'
+        res = req.get_response(fakes.wsgi_app())
+
+        self.assertEqual(res.status_int, 200)
+        dom = minidom.parseString(res.body)
+        transfer_list = dom.getElementsByTagName('transfer')
+        self.assertEqual(transfer_list.item(0).attributes.length, 3)
+        self.assertEqual(transfer_list.item(0).getAttribute('id'),
+                         transfer1['id'])
+        self.assertEqual(transfer_list.item(1).attributes.length, 3)
+        self.assertEqual(transfer_list.item(1).getAttribute('id'),
+                         transfer2['id'])
+
+        db.transfer_destroy(context.get_admin_context(), transfer2['id'])
+        db.transfer_destroy(context.get_admin_context(), transfer1['id'])
+        db.volume_destroy(context.get_admin_context(), volume_id_2)
+        db.volume_destroy(context.get_admin_context(), volume_id_1)
+
+    def test_list_transfers_detail_json(self):
+        volume_id_1 = self._create_volume(size=5)
+        volume_id_2 = self._create_volume(size=5)
+        transfer1 = self._create_transfer(volume_id_1)
+        transfer2 = self._create_transfer(volume_id_2)
+
+        req = webob.Request.blank('/v2/fake/os-volume-transfer/detail')
+        req.method = 'GET'
+        req.headers['Content-Type'] = 'application/json'
+        req.headers['Accept'] = 'application/json'
+        res = req.get_response(fakes.wsgi_app())
+        res_dict = json.loads(res.body)
+
+        self.assertEqual(res.status_int, 200)
+        self.assertEqual(len(res_dict['transfers'][0]), 5)
+        self.assertEqual(res_dict['transfers'][0]['name'],
+                         'test_transfer')
+        self.assertEqual(res_dict['transfers'][0]['id'], transfer1['id'])
+        self.assertEqual(res_dict['transfers'][0]['volume_id'], volume_id_1)
+
+        self.assertEqual(len(res_dict['transfers'][1]), 5)
+        self.assertEqual(res_dict['transfers'][1]['name'],
+                         'test_transfer')
+        self.assertEqual(res_dict['transfers'][1]['id'], transfer2['id'])
+        self.assertEqual(res_dict['transfers'][1]['volume_id'], volume_id_2)
+
+        db.transfer_destroy(context.get_admin_context(), transfer2['id'])
+        db.transfer_destroy(context.get_admin_context(), transfer1['id'])
+        db.volume_destroy(context.get_admin_context(), volume_id_2)
+        db.volume_destroy(context.get_admin_context(), volume_id_1)
+
+    def test_list_transfers_detail_xml(self):
+        volume_id_1 = self._create_volume(size=5)
+        volume_id_2 = self._create_volume(size=5)
+        transfer1 = self._create_transfer(volume_id_1)
+        transfer2 = self._create_transfer(volume_id_2)
+
+        req = webob.Request.blank('/v2/fake/os-volume-transfer/detail')
+        req.method = 'GET'
+        req.headers['Content-Type'] = 'application/xml'
+        req.headers['Accept'] = 'application/xml'
+        res = req.get_response(fakes.wsgi_app())
+
+        self.assertEqual(res.status_int, 200)
+        dom = minidom.parseString(res.body)
+        transfer_detail = dom.getElementsByTagName('transfer')
+
+        self.assertEqual(transfer_detail.item(0).attributes.length, 4)
+        self.assertEqual(
+            transfer_detail.item(0).getAttribute('name'), 'test_transfer')
+        self.assertEqual(
+            transfer_detail.item(0).getAttribute('id'), transfer1['id'])
+        self.assertEqual(transfer_detail.item(0).getAttribute('volume_id'),
+                         volume_id_1)
+
+        self.assertEqual(transfer_detail.item(1).attributes.length, 4)
+        self.assertEqual(
+            transfer_detail.item(1).getAttribute('name'), 'test_transfer')
+        self.assertEqual(
+            transfer_detail.item(1).getAttribute('id'), transfer2['id'])
+        self.assertEqual(transfer_detail.item(1).getAttribute('volume_id'),
+                         volume_id_2)
+
+        db.transfer_destroy(context.get_admin_context(), transfer2['id'])
+        db.transfer_destroy(context.get_admin_context(), transfer1['id'])
+        db.volume_destroy(context.get_admin_context(), volume_id_2)
+        db.volume_destroy(context.get_admin_context(), volume_id_1)
+
+    def test_create_transfer_json(self):
+        volume_id = self._create_volume(status='available', size=5)
+        body = {"transfer": {"display_name": "transfer1",
+                             "volume_id": volume_id}}
+
+        req = webob.Request.blank('/v2/fake/os-volume-transfer')
+        req.method = 'POST'
+        req.headers['Content-Type'] = 'application/json'
+        req.body = json.dumps(body)
+        res = req.get_response(fakes.wsgi_app())
+
+        res_dict = json.loads(res.body)
+        LOG.info(res_dict)
+
+        self.assertEqual(res.status_int, 202)
+        self.assertTrue('id' in res_dict['transfer'])
+        self.assertTrue('auth_key' in res_dict['transfer'])
+        self.assertTrue('created_at' in res_dict['transfer'])
+        self.assertTrue('name' in res_dict['transfer'])
+        self.assertTrue('volume_id' in res_dict['transfer'])
+
+        db.volume_destroy(context.get_admin_context(), volume_id)
+
+    def test_create_transfer_xml(self):
+        volume_size = 2
+        volume_id = self._create_volume(status='available', size=volume_size)
+
+        req = webob.Request.blank('/v2/fake/os-volume-transfer')
+        req.body = ('<transfer display_name="transfer-001" '
+                    'volume_id="%s"/>' % volume_id)
+        req.method = 'POST'
+        req.headers['Content-Type'] = 'application/xml'
+        req.headers['Accept'] = 'application/xml'
+        res = req.get_response(fakes.wsgi_app())
+
+        self.assertEqual(res.status_int, 202)
+        dom = minidom.parseString(res.body)
+        transfer = dom.getElementsByTagName('transfer')
+        self.assertTrue(transfer.item(0).hasAttribute('id'))
+        self.assertTrue(transfer.item(0).hasAttribute('auth_key'))
+        self.assertTrue(transfer.item(0).hasAttribute('created_at'))
+        self.assertTrue(transfer.item(0).hasAttribute('name'))
+        self.assertTrue(transfer.item(0).hasAttribute('volume_id'))
+        db.volume_destroy(context.get_admin_context(), volume_id)
+
+    def test_create_transfer_with_no_body(self):
+        req = webob.Request.blank('/v2/fake/os-volume-transfer')
+        req.body = json.dumps(None)
+        req.method = 'POST'
+        req.headers['Content-Type'] = 'application/json'
+        req.headers['Accept'] = 'application/json'
+        res = req.get_response(fakes.wsgi_app())
+        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'],
+                         'The server could not comply with the request since'
+                         ' it is either malformed or otherwise incorrect.')
+
+    def test_create_transfer_with_body_KeyError(self):
+        body = {"transfer": {"display_name": "transfer1"}}
+        req = webob.Request.blank('/v2/fake/os-volume-transfer')
+        req.method = 'POST'
+        req.headers['Content-Type'] = 'application/json'
+        req.body = json.dumps(body)
+        res = req.get_response(fakes.wsgi_app())
+        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_create_transfer_with_VolumeNotFound(self):
+        body = {"transfer": {"display_name": "transfer1",
+                             "volume_id": 1234}}
+
+        req = webob.Request.blank('/v2/fake/os-volume-transfer')
+        req.method = 'POST'
+        req.headers['Content-Type'] = 'application/json'
+        req.body = json.dumps(body)
+        res = req.get_response(fakes.wsgi_app())
+        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'],
+                         'Volume 1234 could not be found.')
+
+    def test_create_transfer_with_InvalidVolume(self):
+        volume_id = self._create_volume(status='attached')
+        body = {"transfer": {"display_name": "transfer1",
+                             "volume_id": volume_id}}
+        req = webob.Request.blank('/v2/fake/os-volume-transfer')
+        req.method = 'POST'
+        req.headers['Content-Type'] = 'application/json'
+        req.body = json.dumps(body)
+        res = req.get_response(fakes.wsgi_app())
+        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 volume: status must be available')
+
+        db.volume_destroy(context.get_admin_context(), volume_id)
+
+    def test_delete_transfer_awaiting_transfer(self):
+        volume_id = self._create_volume()
+        transfer = self._create_transfer(volume_id)
+        req = webob.Request.blank('/v2/fake/os-volume-transfer/%s' %
+                                  transfer['id'])
+        req.method = 'DELETE'
+        req.headers['Content-Type'] = 'application/json'
+        res = req.get_response(fakes.wsgi_app())
+
+        self.assertEqual(res.status_int, 202)
+
+        # verify transfer has been deleted
+        req = webob.Request.blank('/v2/fake/os-volume-transfer/%s' %
+                                  transfer['id'])
+        req.method = 'GET'
+        req.headers['Content-Type'] = 'application/json'
+        res = req.get_response(fakes.wsgi_app())
+        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'],
+                         'Transfer %s could not be found.' % transfer['id'])
+        self.assertEqual(db.volume_get(context.get_admin_context(),
+                         volume_id)['status'], 'available')
+
+        db.volume_destroy(context.get_admin_context(), volume_id)
+
+    def test_delete_transfer_with_transfer_NotFound(self):
+        req = webob.Request.blank('/v2/fake/os-volume-transfer/9999')
+        req.method = 'DELETE'
+        req.headers['Content-Type'] = 'application/json'
+        res = req.get_response(fakes.wsgi_app())
+        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'],
+                         'Transfer 9999 could not be found.')
+
+    def test_accept_transfer_volume_id_specified_json(self):
+        volume_id = self._create_volume()
+        transfer = self._create_transfer(volume_id)
+
+        body = {"accept": {"id": transfer['id'],
+                           "auth_key": transfer['auth_key']}}
+        req = webob.Request.blank('/v2/fake/os-volume-transfer/%s/accept' %
+                                  transfer['id'])
+        req.method = 'POST'
+        req.headers['Content-Type'] = 'application/json'
+        req.body = json.dumps(body)
+        res = req.get_response(fakes.wsgi_app())
+        res_dict = json.loads(res.body)
+
+        self.assertEqual(res.status_int, 202)
+        self.assertEqual(res_dict['transfer']['id'], transfer['id'])
+        self.assertEqual(res_dict['transfer']['volume_id'], volume_id)
+
+    def test_accept_transfer_volume_id_specified_xml(self):
+        volume_id = self._create_volume(size=5)
+        transfer = self._create_transfer(volume_id)
+
+        req = webob.Request.blank('/v2/fake/os-volume-transfer/%s/accept' %
+                                  transfer['id'])
+        req.body = '<accept auth_key="%s"/>' % transfer['auth_key']
+        req.method = 'POST'
+        req.headers['Content-Type'] = 'application/xml'
+        req.headers['Accept'] = 'application/xml'
+        res = req.get_response(fakes.wsgi_app())
+
+        self.assertEqual(res.status_int, 202)
+        dom = minidom.parseString(res.body)
+        accept = dom.getElementsByTagName('transfer')
+        self.assertEqual(accept.item(0).getAttribute('id'),
+                         transfer['id'])
+        self.assertEqual(accept.item(0).getAttribute('volume_id'), volume_id)
+
+        db.volume_destroy(context.get_admin_context(), volume_id)
+
+    def test_accept_transfer_with_no_body(self):
+        volume_id = self._create_volume(size=5)
+        transfer = self._create_transfer(volume_id)
+
+        req = webob.Request.blank('/v2/fake/os-volume-transfer/%s/accept' %
+                                  transfer['id'])
+        req.body = json.dumps(None)
+        req.method = 'POST'
+        req.headers['Content-Type'] = 'application/json'
+        req.headers['Accept'] = 'application/json'
+        res = req.get_response(fakes.wsgi_app())
+        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'],
+                         'The server could not comply with the request since'
+                         ' it is either malformed or otherwise incorrect.')
+
+        db.volume_destroy(context.get_admin_context(), volume_id)
+
+    def test_accept_transfer_with_body_KeyError(self):
+        volume_id = self._create_volume(size=5)
+        transfer = self._create_transfer(volume_id)
+
+        req = webob.Request.blank('/v2/fake/os-volume-transfer/%s/accept' %
+                                  transfer['id'])
+        body = {"": {}}
+        req.method = 'POST'
+        req.headers['Content-Type'] = 'application/json'
+        req.headers['Accept'] = 'application/json'
+        req.body = json.dumps(body)
+        res = req.get_response(fakes.wsgi_app())
+
+        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'],
+                         'The server could not comply with the request since'
+                         ' it is either malformed or otherwise incorrect.')
+
+    def test_accept_transfer_invalid_id_auth_key(self):
+        volume_id = self._create_volume()
+        transfer = self._create_transfer(volume_id)
+
+        body = {"accept": {"id": transfer['id'],
+                           "auth_key": 1}}
+        req = webob.Request.blank('/v2/fake/os-volume-transfer/%s/accept' %
+                                  transfer['id'])
+        req.method = 'POST'
+        req.headers['Content-Type'] = 'application/json'
+        req.body = json.dumps(body)
+        res = req.get_response(fakes.wsgi_app())
+        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 auth key: Attempt to transfer %s with '
+                         'invalid auth key.' % transfer['id'])
+
+        db.transfer_destroy(context.get_admin_context(), transfer['id'])
+        db.volume_destroy(context.get_admin_context(), volume_id)
+
+    def test_accept_transfer_with_invalid_transfer(self):
+        volume_id = self._create_volume()
+        transfer = self._create_transfer(volume_id)
+
+        body = {"accept": {"id": transfer['id'],
+                           "auth_key": 1}}
+        req = webob.Request.blank('/v2/fake/os-volume-transfer/1/accept')
+        req.method = 'POST'
+        req.headers['Content-Type'] = 'application/json'
+        req.body = json.dumps(body)
+        res = req.get_response(fakes.wsgi_app())
+        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'],
+                         'TransferNotFound: Transfer 1 could not be found.')
+
+        db.transfer_destroy(context.get_admin_context(), transfer['id'])
+        db.volume_destroy(context.get_admin_context(), volume_id)
+
+    def test_accept_transfer_with_VolumeSizeExceedsAvailableQuota(self):
+
+        def fake_transfer_api_accept_throwing_VolumeSizeExceedsAvailableQuota(
+                cls, context, transfer, volume_id):
+            raise exception.VolumeSizeExceedsAvailableQuota()
+
+        self.stubs.Set(
+            cinder.transfer.API,
+            'accept',
+            fake_transfer_api_accept_throwing_VolumeSizeExceedsAvailableQuota)
+
+        volume_id = self._create_volume()
+        transfer = self._create_transfer(volume_id)
+
+        body = {"accept": {"id": transfer['id'],
+                           "auth_key": transfer['auth_key']}}
+        req = webob.Request.blank('/v2/fake/os-volume-transfer/%s/accept' %
+                                  transfer['id'])
+
+        req.method = 'POST'
+        req.headers['Content-Type'] = 'application/json'
+        req.body = json.dumps(body)
+        res = req.get_response(fakes.wsgi_app())
+        res_dict = json.loads(res.body)
+
+        self.assertEqual(res.status_int, 413)
+        self.assertEqual(res_dict['overLimit']['code'], 413)
+        self.assertEqual(res_dict['overLimit']['message'],
+                         'Requested volume or snapshot exceeds allowed '
+                         'Gigabytes quota')
+
+    def test_accept_transfer_with_VolumeLimitExceeded(self):
+
+        def fake_transfer_api_accept_throwing_VolumeLimitExceeded(cls,
+                                                                 context,
+                                                                 transfer,
+                                                                 volume_id):
+            raise exception.VolumeLimitExceeded(allowed=1)
+
+        self.stubs.Set(cinder.transfer.API, 'accept',
+                       fake_transfer_api_accept_throwing_VolumeLimitExceeded)
+
+        volume_id = self._create_volume()
+        transfer = self._create_transfer(volume_id)
+
+        body = {"accept": {"id": transfer['id'],
+                           "auth_key": transfer['auth_key']}}
+        req = webob.Request.blank('/v2/fake/os-volume-transfer/%s/accept' %
+                                  transfer['id'])
+
+        req.method = 'POST'
+        req.headers['Content-Type'] = 'application/json'
+        req.body = json.dumps(body)
+        res = req.get_response(fakes.wsgi_app())
+        res_dict = json.loads(res.body)
+
+        self.assertEqual(res.status_int, 413)
+        self.assertEqual(res_dict['overLimit']['code'], 413)
+        self.assertEqual(res_dict['overLimit']['message'],
+                         'VolumeLimitExceeded: Maximum number of volumes '
+                         'allowed (1) exceeded')
diff --git a/cinder/tests/db/test_transfers.py b/cinder/tests/db/test_transfers.py
new file mode 100644 (file)
index 0000000..dbf6ce1
--- /dev/null
@@ -0,0 +1,140 @@
+# Copyright (C) 2013 Hewlett-Packard Development Company, L.P.
+# All Rights Reserved.
+#
+#    Licensed under the Apache License, Version 2.0 (the "License"); you may
+#    not use this file except in compliance with the License. You may obtain
+#    a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#    Unless required by applicable law or agreed to in writing, software
+#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+#    License for the specific language governing permissions and limitations
+#    under the License.
+
+"""Tests for transfers table."""
+
+from cinder import context
+from cinder import db
+from cinder import exception
+from cinder.openstack.common import log as logging
+from cinder import test
+
+
+LOG = logging.getLogger(__name__)
+
+
+class TransfersTableTestCase(test.TestCase):
+    """Test case for transfers model."""
+
+    def setUp(self):
+        super(TransfersTableTestCase, self).setUp()
+        self.ctxt = context.RequestContext(user_id='user_id',
+                                           project_id='project_id')
+
+    def tearDown(self):
+        super(TransfersTableTestCase, self).tearDown()
+
+    def _create_volume(self,
+                       display_name='test_volume',
+                       display_description='this is a test volume',
+                       status='available',
+                       size=1):
+        """Create a volume object."""
+        vol = {}
+        vol['size'] = size
+        vol['user_id'] = self.ctxt.user_id
+        vol['project_id'] = self.ctxt.project_id
+        vol['status'] = status
+        vol['display_name'] = display_name
+        vol['display_description'] = display_description
+        vol['attach_status'] = 'detached'
+        return db.volume_create(self.ctxt, vol)['id']
+
+    def _create_transfer(self, volume_id=None):
+        """Create a transfer object."""
+        transfer = {'display_name': 'display_name',
+                    'salt': 'salt',
+                    'crypt_hash': 'crypt_hash'}
+        if volume_id is not None:
+            transfer['volume_id'] = volume_id
+        return db.transfer_create(self.ctxt, transfer)['id']
+
+    def test_transfer_create(self):
+        # If the volume_id is Null a KeyError exception will be raised.
+        self.assertRaises(KeyError,
+                          self._create_transfer)
+
+        volume_id = self._create_volume(size=1)
+        self._create_transfer(volume_id)
+
+    def test_transfer_get(self):
+        volume_id1 = self._create_volume(size=1)
+        xfer_id1 = self._create_transfer(volume_id1)
+
+        xfer = db.transfer_get(self.ctxt, xfer_id1)
+        self.assertEquals(xfer.volume_id, volume_id1, "Unexpected volume_id")
+
+        nctxt = context.RequestContext(user_id='new_user_id',
+                                          project_id='new_project_id')
+        self.assertRaises(exception.TransferNotFound,
+                          db.transfer_get, nctxt, xfer_id1)
+
+        xfer = db.transfer_get(nctxt.elevated(), xfer_id1)
+        self.assertEquals(xfer.volume_id, volume_id1, "Unexpected volume_id")
+
+    def test_transfer_get_all(self):
+        volume_id1 = self._create_volume(size=1)
+        volume_id2 = self._create_volume(size=1)
+        self._create_transfer(volume_id1)
+        self._create_transfer(volume_id2)
+
+        self.assertRaises(exception.NotAuthorized,
+                          db.transfer_get_all,
+                          self.ctxt)
+        xfer = db.transfer_get_all(context.get_admin_context())
+        self.assertEquals(len(xfer), 2,
+                          "Unexpected number of transfer records")
+
+        xfer = db.transfer_get_all_by_project(self.ctxt, self.ctxt.project_id)
+        self.assertEquals(len(xfer), 2,
+                          "Unexpected number of transfer records")
+
+        nctxt = context.RequestContext(user_id='new_user_id',
+                                          project_id='new_project_id')
+        self.assertRaises(exception.NotAuthorized,
+                          db.transfer_get_all_by_project,
+                          nctxt, self.ctxt.project_id)
+        xfer = db.transfer_get_all_by_project(nctxt.elevated(),
+                                              self.ctxt.project_id)
+        self.assertEquals(len(xfer), 2,
+                          "Unexpected number of transfer records")
+
+    def test_transfer_destroy(self):
+        volume_id = self._create_volume(size=1)
+        volume_id2 = self._create_volume(size=1)
+        xfer_id1 = self._create_transfer(volume_id)
+        xfer_id2 = self._create_transfer(volume_id2)
+
+        xfer = db.transfer_get_all(context.get_admin_context())
+        self.assertEquals(len(xfer), 2,
+                          "Unexpected number of transfer records")
+        self.assertFalse(xfer[0]['deleted'], "Deleted flag is set")
+
+        db.transfer_destroy(self.ctxt, xfer_id1)
+        xfer = db.transfer_get_all(context.get_admin_context())
+        self.assertEquals(len(xfer), 1,
+                          "Unexpected number of transfer records")
+        self.assertEquals(xfer[0]['id'], xfer_id2,
+                          "Unexpected value for Transfer id")
+
+        nctxt = context.RequestContext(user_id='new_user_id',
+                                       project_id='new_project_id')
+        self.assertRaises(exception.TransferNotFound,
+                          db.transfer_destroy, nctxt, xfer_id2)
+
+        db.transfer_destroy(nctxt.elevated(), xfer_id2)
+        xfer = db.transfer_get_all(context.get_admin_context())
+        self.assertEquals(len(xfer), 0,
+                          "Unexpected number of transfer records")
index c071fb2b00f4832cd810b568f68eb5ca9d0ccd3c..371f48a7cfda9081a565b63e8855b632aa575d6c 100644 (file)
@@ -628,3 +628,47 @@ class TestMigrations(test.TestCase):
 
             self.assertFalse(engine.dialect.has_table(engine.connect(),
                                                       "snapshot_metadata"))
+
+    def test_migration_010(self):
+        """Test adding transfers table works correctly."""
+        for (key, engine) in self.engines.items():
+            migration_api.version_control(engine,
+                                          TestMigrations.REPOSITORY,
+                                          migration.INIT_VERSION)
+            migration_api.upgrade(engine, TestMigrations.REPOSITORY, 9)
+            metadata = sqlalchemy.schema.MetaData()
+            metadata.bind = engine
+
+            migration_api.upgrade(engine, TestMigrations.REPOSITORY, 10)
+
+            self.assertTrue(engine.dialect.has_table(engine.connect(),
+                                                     "transfers"))
+            transfers = sqlalchemy.Table('transfers',
+                                         metadata,
+                                         autoload=True)
+
+            self.assertTrue(isinstance(transfers.c.created_at.type,
+                                       sqlalchemy.types.DATETIME))
+            self.assertTrue(isinstance(transfers.c.updated_at.type,
+                                       sqlalchemy.types.DATETIME))
+            self.assertTrue(isinstance(transfers.c.deleted_at.type,
+                                       sqlalchemy.types.DATETIME))
+            self.assertTrue(isinstance(transfers.c.deleted.type,
+                                       sqlalchemy.types.BOOLEAN))
+            self.assertTrue(isinstance(transfers.c.id.type,
+                                       sqlalchemy.types.VARCHAR))
+            self.assertTrue(isinstance(transfers.c.volume_id.type,
+                                       sqlalchemy.types.VARCHAR))
+            self.assertTrue(isinstance(transfers.c.display_name.type,
+                                       sqlalchemy.types.VARCHAR))
+            self.assertTrue(isinstance(transfers.c.salt.type,
+                                       sqlalchemy.types.VARCHAR))
+            self.assertTrue(isinstance(transfers.c.crypt_hash.type,
+                                       sqlalchemy.types.VARCHAR))
+            self.assertTrue(isinstance(transfers.c.expires_at.type,
+                                       sqlalchemy.types.DATETIME))
+
+            migration_api.downgrade(engine, TestMigrations.REPOSITORY, 9)
+
+            self.assertFalse(engine.dialect.has_table(engine.connect(),
+                                                      "transfers"))
diff --git a/cinder/tests/test_volume_transfer.py b/cinder/tests/test_volume_transfer.py
new file mode 100644 (file)
index 0000000..7c8d192
--- /dev/null
@@ -0,0 +1,133 @@
+# vim: tabstop=4 shiftwidth=4 softtabstop=4
+
+# Copyright (c) 2013 OpenStack LLC.
+#    Licensed under the Apache License, Version 2.0 (the "License"); you may
+#    not use this file except in compliance with the License. You may obtain
+#    a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#    Unless required by applicable law or agreed to in writing, software
+#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+#    License for the specific language governing permissions and limitations
+#    under the License.
+"""Unit Tests for volume transfers."""
+import datetime
+
+from cinder import context
+from cinder import db
+from cinder import exception
+from cinder import flags
+from cinder.openstack.common import log as logging
+from cinder import test
+from cinder.transfer import api as transfer_api
+from cinder.volume import api as cinder_api
+
+LOG = logging.getLogger(__name__)
+
+
+class VolumeTransferTestCase(test.TestCase):
+    """Test cases for volume type code."""
+    def setUp(self):
+        super(VolumeTransferTestCase, self).setUp()
+        self.ctxt = context.RequestContext(user_id='user_id',
+                                           project_id='project_id')
+
+    def _create_volume(self, volume_id, status='available',
+                       user_id=None, project_id=None):
+        if user_id is None:
+            user_id = self.ctxt.user_id
+        if project_id is None:
+            project_id = self.ctxt.project_id
+        vol = {'id': volume_id,
+               'updated_at': datetime.datetime(1, 1, 1, 1, 1, 1),
+               'user_id': user_id,
+               'project_id': project_id,
+               'display_name': 'Display Name',
+               'display_description': 'Display Description',
+               'size': 1,
+               'status': status}
+        volume = db.volume_create(self.ctxt, vol)
+        return volume
+
+    def test_transfer_volume_create_delete(self):
+        tx_api = transfer_api.API()
+        volume = self._create_volume('1')
+        response = tx_api.create(self.ctxt, '1', 'Description')
+        volume = db.volume_get(self.ctxt, '1')
+        self.assertEquals('awaiting-transfer', volume['status'],
+                          'Unexpected state')
+
+        tx_api.delete(self.ctxt, response['id'])
+        volume = db.volume_get(self.ctxt, '1')
+        self.assertEquals('available', volume['status'],
+                          'Unexpected state')
+
+    def test_transfer_invalid_volume(self):
+        tx_api = transfer_api.API()
+        volume = self._create_volume('1', status='in-use')
+        self.assertRaises(exception.InvalidVolume,
+                          tx_api.create,
+                          self.ctxt, '1', 'Description')
+        volume = db.volume_get(self.ctxt, '1')
+        self.assertEquals('in-use', volume['status'],
+                          'Unexpected state')
+
+    def test_transfer_accept(self):
+        tx_api = transfer_api.API()
+        volume = self._create_volume('1')
+        transfer = tx_api.create(self.ctxt, '1', 'Description')
+        volume = db.volume_get(self.ctxt, '1')
+        self.assertEquals('awaiting-transfer', volume['status'],
+                          'Unexpected state')
+
+        self.assertRaises(exception.TransferNotFound,
+                          tx_api.accept,
+                          self.ctxt, '2', transfer['auth_key'])
+
+        self.assertRaises(exception.InvalidAuthKey,
+                          tx_api.accept,
+                          self.ctxt, transfer['id'], 'wrong')
+
+        db.volume_update(self.ctxt, '1', {'status': 'wrong'})
+        self.assertRaises(exception.InvalidVolume,
+                          tx_api.accept,
+                          self.ctxt, transfer['id'], transfer['auth_key'])
+        db.volume_update(self.ctxt, '1', {'status': 'awaiting-transfer'})
+
+        self.ctxt.user_id = 'new_user_id'
+        self.ctxt.project_id = 'new_project_id'
+        response = tx_api.accept(self.ctxt,
+                                 transfer['id'],
+                                 transfer['auth_key'])
+        volume = db.volume_get(self.ctxt, '1')
+        self.assertEquals(volume['project_id'], 'new_project_id',
+                          'Unexpected project id')
+        self.assertEquals(volume['user_id'], 'new_user_id',
+                          'Unexpected user id')
+
+        self.assertEquals(volume['id'], response['volume_id'],
+                         'Unexpected volume id in response.')
+        self.assertEquals(transfer['id'], response['id'],
+                         'Unexpected transfer id in response.')
+
+    def test_transfer_get(self):
+        tx_api = transfer_api.API()
+        volume = self._create_volume('1')
+        transfer = tx_api.create(self.ctxt, volume['id'], 'Description')
+        t = tx_api.get(self.ctxt, transfer['id'])
+        self.assertEquals(t['id'], transfer['id'], 'Unexpected transfer id')
+
+        ts = tx_api.get_all(self.ctxt)
+        self.assertEquals(len(ts), 1, 'Unexpected number of transfers.')
+
+        nctxt = context.RequestContext(user_id='new_user_id',
+                                       project_id='new_project_id')
+        self.assertRaises(exception.TransferNotFound,
+                          tx_api.get,
+                          nctxt,
+                          transfer['id'])
+
+        ts = tx_api.get_all(nctxt)
+        self.assertEquals(len(ts), 0, 'Unexpected transfers listed.')
diff --git a/cinder/transfer/__init__.py b/cinder/transfer/__init__.py
new file mode 100644 (file)
index 0000000..6979b82
--- /dev/null
@@ -0,0 +1,23 @@
+# Copyright (C) 2013 Hewlett-Packard Development Company, L.P.
+# All Rights Reserved.
+#
+#    Licensed under the Apache License, Version 2.0 (the "License"); you may
+#    not use this file except in compliance with the License. You may obtain
+#    a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#    Unless required by applicable law or agreed to in writing, software
+#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+#    License for the specific language governing permissions and limitations
+#    under the License.
+
+# Importing full names to not pollute the namespace and cause possible
+# collisions with use of 'from cinder.transfer import <foo>' elsewhere.
+
+import cinder.flags
+import cinder.openstack.common.importutils
+
+API = cinder.openstack.common.importutils.import_class(
+        cinder.flags.FLAGS.transfer_api_class)
diff --git a/cinder/transfer/api.py b/cinder/transfer/api.py
new file mode 100644 (file)
index 0000000..e139951
--- /dev/null
@@ -0,0 +1,199 @@
+# Copyright (C) 2013 Hewlett-Packard Development Company, L.P.
+# All Rights Reserved.
+#
+#    Licensed under the Apache License, Version 2.0 (the "License"); you may
+#    not use this file except in compliance with the License. You may obtain
+#    a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#    Unless required by applicable law or agreed to in writing, software
+#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+#    License for the specific language governing permissions and limitations
+#    under the License.
+
+"""
+Handles all requests relating to transferring ownership of volumes.
+"""
+
+import datetime
+import hashlib
+import hmac
+import random
+
+from oslo.config import cfg
+
+from cinder.db import base
+from cinder import exception
+from cinder import flags
+from cinder.openstack.common import log as logging
+from cinder import quota
+from cinder.volume import api as volume_api
+
+volume_transfer_opts = [
+    cfg.IntOpt('volume_transfer_salt_length', default=8,
+               help='The number of characters in the salt.'),
+    cfg.IntOpt('volume_transfer_key_length', default=16,
+               help='The number of characters in the '
+                'autogenerated auth key.'), ]
+
+FLAGS = flags.FLAGS
+FLAGS.register_opts(volume_transfer_opts)
+
+LOG = logging.getLogger(__name__)
+QUOTAS = quota.QUOTAS
+
+
+class API(base.Base):
+    """API for interacting volume transfers."""
+
+    def __init__(self, db_driver=None):
+        self.volume_api = volume_api.API()
+        super(API, self).__init__(db_driver)
+
+    def get(self, context, transfer_id):
+        rv = self.db.transfer_get(context, transfer_id)
+        return dict(rv.iteritems())
+
+    def delete(self, context, transfer_id):
+        """
+        Make the RPC call to delete a volume transfer.
+        """
+        transfer = self.db.transfer_get(context, transfer_id)
+
+        volume_ref = self.db.volume_get(context, transfer.volume_id)
+        if volume_ref['status'] != 'awaiting-transfer':
+            msg = _("Volume in unexpected state")
+            LOG.error(msg)
+        self.db.transfer_destroy(context, transfer_id)
+
+    def get_all(self, context, filters={}):
+        if context.is_admin and 'all_tenants' in filters:
+            transfers = self.db.transfer_get_all(context)
+        else:
+            transfers = self.db.transfer_get_all_by_project(context,
+                                                            context.project_id)
+        return transfers
+
+    def _get_random_string(self, length):
+        """Get a random hex string of the specified length."""
+        rndstr = ""
+        random.seed(datetime.datetime.now().microsecond)
+        while len(rndstr) < length:
+            rndstr += hashlib.sha224(str(random.random())).hexdigest()
+
+        return rndstr[0:length]
+
+    def _get_crypt_hash(self, salt, auth_key):
+        """Generate a random hash based on the salt and the auth key."""
+        return hmac.new(str(salt),
+                        str(auth_key),
+                        hashlib.sha1).hexdigest()
+
+    def create(self, context, volume_id, display_name):
+        """Creates an entry in the transfers table."""
+        LOG.info("Generating transfer record for volume %s" % volume_id)
+        volume_ref = self.db.volume_get(context, volume_id)
+        if volume_ref['status'] != "available":
+            raise exception.InvalidVolume(reason=_("status must be available"))
+
+        # The salt is just a short random string.
+        salt = self._get_random_string(FLAGS.volume_transfer_salt_length)
+        auth_key = self._get_random_string(FLAGS.volume_transfer_key_length)
+        crypt_hash = self._get_crypt_hash(salt, auth_key)
+
+        # TODO(ollie): Transfer expiry needs to be implemented.
+        transfer_rec = {'volume_id': volume_id,
+                        'display_name': display_name,
+                        'salt': salt,
+                        'crypt_hash': crypt_hash,
+                        'expires_at': None}
+
+        try:
+            transfer = self.db.transfer_create(context, transfer_rec)
+        except Exception:
+            LOG.error(_("Failed to create transfer record for %s") % volume_id)
+            raise
+        return {'id': transfer['id'],
+                'volume_id': transfer['volume_id'],
+                'display_name': transfer['display_name'],
+                'auth_key': auth_key,
+                'created_at': transfer['created_at']}
+
+    def accept(self, context, transfer_id, auth_key):
+        """Accept a volume that has been offered for transfer."""
+        # We must use an elevated context to see the volume that is still
+        # owned by the donor.
+        transfer = self.db.transfer_get(context.elevated(), transfer_id)
+
+        crypt_hash = self._get_crypt_hash(transfer['salt'], auth_key)
+        if crypt_hash != transfer['crypt_hash']:
+            msg = (_("Attempt to transfer %s with invalid auth key.") %
+                   transfer_id)
+            LOG.error(msg)
+            raise exception.InvalidAuthKey(reason=msg)
+
+        volume_id = transfer['volume_id']
+        vol_ref = self.db.volume_get(context.elevated(), volume_id)
+
+        try:
+            reservations = QUOTAS.reserve(context, volumes=1,
+                                          gigabytes=vol_ref['size'])
+        except exception.OverQuota as e:
+            overs = e.kwargs['overs']
+            usages = e.kwargs['usages']
+            quotas = e.kwargs['quotas']
+
+            def _consumed(name):
+                return (usages[name]['reserved'] + usages[name]['in_use'])
+
+            if 'gigabytes' in overs:
+                msg = _("Quota exceeded for %(s_pid)s, tried to create "
+                        "%(s_size)sG volume (%(d_consumed)dG of %(d_quota)dG "
+                        "already consumed)")
+                LOG.warn(msg % {'s_pid': context.project_id,
+                                's_size': vol_ref['size'],
+                                'd_consumed': _consumed('gigabytes'),
+                                'd_quota': quotas['gigabytes']})
+                raise exception.VolumeSizeExceedsAvailableQuota()
+            elif 'volumes' in overs:
+                msg = _("Quota exceeded for %(s_pid)s, tried to create "
+                        "volume (%(d_consumed)d volumes "
+                        "already consumed)")
+                LOG.warn(msg % {'s_pid': context.project_id,
+                                'd_consumed': _consumed('volumes')})
+                raise exception.VolumeLimitExceeded(allowed=quotas['volumes'])
+        try:
+            donor_id = vol_ref['project_id']
+            donor_reservations = QUOTAS.reserve(context,
+                                                project_id=donor_id,
+                                                volumes=-1,
+                                                gigabytes=-vol_ref['size'])
+        except Exception:
+            donor_reservations = None
+            LOG.exception(_("Failed to update quota donating volume"
+                            "transfer id %s") % transfer_id)
+
+        try:
+            # Transfer ownership of the volume now, must use an elevated
+            # context.
+            self.db.transfer_accept(context.elevated(),
+                                    transfer_id,
+                                    context.user_id,
+                                    context.project_id)
+            QUOTAS.commit(context, reservations)
+            if donor_reservations:
+                QUOTAS.commit(context, donor_reservations, project_id=donor_id)
+            LOG.info(_("Volume %s has been transferred.") % volume_id)
+        except Exception as exc:
+            QUOTAS.rollback(context, reservations)
+            if donor_reservations:
+                QUOTAS.rollback(context, donor_reservations,
+                                project_id=donor_id)
+            raise exc
+
+        vol_ref = self.db.volume_get(context, volume_id)
+        return {'id': transfer_id,
+                'display_name': transfer['display_name'],
+                'volume_id': vol_ref['id']}