1# Copyright (C) 2013 Hewlett-Packard Development Company, L.P.
2# All Rights Reserved.
3#
4#    Licensed under the Apache License, Version 2.0 (the "License"); you may
5#    not use this file except in compliance with the License. You may obtain
6#    a copy of the License at
7#
8#         http://www.apache.org/licenses/LICENSE-2.0
9#
10#    Unless required by applicable law or agreed to in writing, software
11#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13#    License for the specific language governing permissions and limitations
14#    under the License.
15
16"""
17Handles all requests relating to transferring ownership of volumes.
18"""
19
20
21import hashlib
22import hmac
23import os
24
25from oslo_config import cfg
26from oslo_log import log as logging
27from oslo_utils import excutils
28import six
29
30from cinder.db import base
31from cinder import exception
32from cinder.i18n import _
33from cinder import objects
34from cinder.policies import volume_transfer as policy
35from cinder import quota
36from cinder import quota_utils
37from cinder.volume import api as volume_api
38from cinder.volume import utils as volume_utils
39
40
41volume_transfer_opts = [
42    cfg.IntOpt('volume_transfer_salt_length', default=8,
43               help='The number of characters in the salt.'),
44    cfg.IntOpt('volume_transfer_key_length', default=16,
45               help='The number of characters in the '
46               'autogenerated auth key.'), ]
47
48CONF = cfg.CONF
49CONF.register_opts(volume_transfer_opts)
50
51LOG = logging.getLogger(__name__)
52QUOTAS = quota.QUOTAS
53
54
55class API(base.Base):
56    """API for interacting volume transfers."""
57
58    def __init__(self, db_driver=None):
59        self.volume_api = volume_api.API()
60        super(API, self).__init__(db_driver)
61
62    def get(self, context, transfer_id):
63        context.authorize(policy.GET_POLICY)
64        rv = self.db.transfer_get(context, transfer_id)
65        return dict(rv)
66
67    def delete(self, context, transfer_id):
68        """Make the RPC call to delete a volume transfer."""
69        transfer = self.db.transfer_get(context, transfer_id)
70
71        volume_ref = self.db.volume_get(context, transfer.volume_id)
72        context.authorize(policy.DELETE_POLICY, target_obj=volume_ref)
73        volume_utils.notify_about_volume_usage(context, volume_ref,
74                                               "transfer.delete.start")
75        if volume_ref['status'] != 'awaiting-transfer':
76            LOG.error("Volume in unexpected state")
77        self.db.transfer_destroy(context, transfer_id)
78        volume_utils.notify_about_volume_usage(context, volume_ref,
79                                               "transfer.delete.end")
80
81    def get_all(self, context, filters=None):
82        filters = filters or {}
83        context.authorize(policy.GET_ALL_POLICY)
84        if context.is_admin and 'all_tenants' in filters:
85            transfers = self.db.transfer_get_all(context)
86        else:
87            transfers = self.db.transfer_get_all_by_project(context,
88                                                            context.project_id)
89        return transfers
90
91    def _get_random_string(self, length):
92        """Get a random hex string of the specified length."""
93        rndstr = ""
94
95        # Note that the string returned by this function must contain only
96        # characters that the recipient can enter on their keyboard. The
97        # function ssh224().hexdigit() achieves this by generating a hash
98        # which will only contain hexadecimal digits.
99        while len(rndstr) < length:
100            rndstr += hashlib.sha224(os.urandom(255)).hexdigest()
101
102        return rndstr[0:length]
103
104    def _get_crypt_hash(self, salt, auth_key):
105        """Generate a random hash based on the salt and the auth key."""
106        if not isinstance(salt, (six.binary_type, six.text_type)):
107            salt = str(salt)
108        if isinstance(salt, six.text_type):
109            salt = salt.encode('utf-8')
110        if not isinstance(auth_key, (six.binary_type, six.text_type)):
111            auth_key = str(auth_key)
112        if isinstance(auth_key, six.text_type):
113            auth_key = auth_key.encode('utf-8')
114        return hmac.new(salt, auth_key, hashlib.sha1).hexdigest()
115
116    def create(self, context, volume_id, display_name):
117        """Creates an entry in the transfers table."""
118        LOG.info("Generating transfer record for volume %s", volume_id)
119        volume_ref = self.db.volume_get(context, volume_id)
120        context.authorize(policy.CREATE_POLICY, target_obj=volume_ref)
121        if volume_ref['status'] != "available":
122            raise exception.InvalidVolume(reason=_("status must be available"))
123        if volume_ref['encryption_key_id'] is not None:
124            raise exception.InvalidVolume(
125                reason=_("transferring encrypted volume is not supported"))
126
127        volume_utils.notify_about_volume_usage(context, volume_ref,
128                                               "transfer.create.start")
129        # The salt is just a short random string.
130        salt = self._get_random_string(CONF.volume_transfer_salt_length)
131        auth_key = self._get_random_string(CONF.volume_transfer_key_length)
132        crypt_hash = self._get_crypt_hash(salt, auth_key)
133
134        # TODO(ollie): Transfer expiry needs to be implemented.
135        transfer_rec = {'volume_id': volume_id,
136                        'display_name': display_name,
137                        'salt': salt,
138                        'crypt_hash': crypt_hash,
139                        'expires_at': None}
140
141        try:
142            transfer = self.db.transfer_create(context, transfer_rec)
143        except Exception:
144            LOG.error("Failed to create transfer record for %s", volume_id)
145            raise
146        volume_utils.notify_about_volume_usage(context, volume_ref,
147                                               "transfer.create.end")
148        return {'id': transfer['id'],
149                'volume_id': transfer['volume_id'],
150                'display_name': transfer['display_name'],
151                'auth_key': auth_key,
152                'created_at': transfer['created_at']}
153
154    def accept(self, context, transfer_id, auth_key):
155        """Accept a volume that has been offered for transfer."""
156        # We must use an elevated context to see the volume that is still
157        # owned by the donor.
158        context.authorize(policy.ACCEPT_POLICY)
159        transfer = self.db.transfer_get(context.elevated(), transfer_id)
160
161        crypt_hash = self._get_crypt_hash(transfer['salt'], auth_key)
162        if crypt_hash != transfer['crypt_hash']:
163            msg = (_("Attempt to transfer %s with invalid auth key.") %
164                   transfer_id)
165            LOG.error(msg)
166            raise exception.InvalidAuthKey(reason=msg)
167
168        volume_id = transfer['volume_id']
169        vol_ref = objects.Volume.get_by_id(context.elevated(), volume_id)
170        if vol_ref['consistencygroup_id']:
171            msg = _("Volume %s must not be part of a consistency "
172                    "group.") % vol_ref['id']
173            LOG.error(msg)
174            raise exception.InvalidVolume(reason=msg)
175
176        try:
177            values = {'per_volume_gigabytes': vol_ref.size}
178            QUOTAS.limit_check(context, project_id=context.project_id,
179                               **values)
180        except exception.OverQuota as e:
181            quotas = e.kwargs['quotas']
182            raise exception.VolumeSizeExceedsLimit(
183                size=vol_ref.size, limit=quotas['per_volume_gigabytes'])
184
185        try:
186            reserve_opts = {'volumes': 1, 'gigabytes': vol_ref.size}
187            QUOTAS.add_volume_type_opts(context,
188                                        reserve_opts,
189                                        vol_ref.volume_type_id)
190            reservations = QUOTAS.reserve(context, **reserve_opts)
191        except exception.OverQuota as e:
192            quota_utils.process_reserve_over_quota(context, e,
193                                                   resource='volumes',
194                                                   size=vol_ref.size)
195        try:
196            donor_id = vol_ref['project_id']
197            reserve_opts = {'volumes': -1, 'gigabytes': -vol_ref.size}
198            QUOTAS.add_volume_type_opts(context,
199                                        reserve_opts,
200                                        vol_ref.volume_type_id)
201            donor_reservations = QUOTAS.reserve(context.elevated(),
202                                                project_id=donor_id,
203                                                **reserve_opts)
204        except Exception:
205            donor_reservations = None
206            LOG.exception("Failed to update quota donating volume"
207                          " transfer id %s", transfer_id)
208
209        volume_utils.notify_about_volume_usage(context, vol_ref,
210                                               "transfer.accept.start")
211        try:
212            # Transfer ownership of the volume now, must use an elevated
213            # context.
214            self.volume_api.accept_transfer(context,
215                                            vol_ref,
216                                            context.user_id,
217                                            context.project_id)
218            self.db.transfer_accept(context.elevated(),
219                                    transfer_id,
220                                    context.user_id,
221                                    context.project_id)
222            QUOTAS.commit(context, reservations)
223            if donor_reservations:
224                QUOTAS.commit(context, donor_reservations, project_id=donor_id)
225            LOG.info("Volume %s has been transferred.", volume_id)
226        except Exception:
227            with excutils.save_and_reraise_exception():
228                QUOTAS.rollback(context, reservations)
229                if donor_reservations:
230                    QUOTAS.rollback(context, donor_reservations,
231                                    project_id=donor_id)
232
233        vol_ref = self.db.volume_get(context, volume_id)
234        volume_utils.notify_about_volume_usage(context, vol_ref,
235                                               "transfer.accept.end")
236        return {'id': transfer_id,
237                'display_name': transfer['display_name'],
238                'volume_id': vol_ref['id']}
239