Coverage for manila/transfer/api.py: 78%
241 statements
« prev ^ index » next coverage.py v7.11.0, created at 2026-02-18 22:19 +0000
« prev ^ index » next coverage.py v7.11.0, created at 2026-02-18 22:19 +0000
1# Copyright (C) 2022 China Telecom Digital Intelligence.
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.
16"""
17Handles all requests relating to transferring ownership of shares.
18"""
21import hashlib
22import hmac
23import os
25from oslo_config import cfg
26from oslo_log import log as logging
27from oslo_utils import excutils
28from oslo_utils import strutils
30from manila.common import constants
31from manila.db import base
32from manila import exception
33from manila.i18n import _
34from manila import policy
35from manila import quota
36from manila.share import api as share_api
37from manila.share import share_types
38from manila.share import utils as share_utils
41share_transfer_opts = [
42 cfg.IntOpt('share_transfer_salt_length',
43 default=8,
44 help='The number of characters in the salt.',
45 min=8,
46 max=255),
47 cfg.IntOpt('share_transfer_key_length',
48 default=16,
49 help='The number of characters in the autogenerated auth key.',
50 min=16,
51 max=255),
52]
54CONF = cfg.CONF
55CONF.register_opts(share_transfer_opts)
57LOG = logging.getLogger(__name__)
58QUOTAS = quota.QUOTAS
61class API(base.Base):
62 """API for interacting share transfers."""
64 def __init__(self):
65 self.share_api = share_api.API()
66 super().__init__()
68 def get(self, context, transfer_id):
69 transfer = self.db.transfer_get(context, transfer_id)
70 return transfer
72 def delete(self, context, transfer_id):
73 """Delete a share transfer."""
74 transfer = self.db.transfer_get(context, transfer_id)
75 policy.check_policy(context, 'share_transfer', 'delete', target_obj={
76 'project_id': transfer['source_project_id']})
77 update_share_status = True
78 share_ref = None
79 try:
80 share_ref = self.db.share_get(context, transfer.resource_id)
81 except exception.NotFound:
82 update_share_status = False
83 if update_share_status: 83 ↛ 85line 83 didn't jump to line 85 because the condition on line 83 was always true
84 share_instance = share_ref['instance']
85 if share_ref['status'] != constants.STATUS_AWAITING_TRANSFER:
86 msg = (_('Transfer %(transfer_id)s: share id %(share_id)s '
87 'expected in awaiting_transfer state.'))
88 msg_payload = {'transfer_id': transfer_id,
89 'share_id': share_ref['id']}
90 LOG.error(msg, msg_payload)
91 raise exception.InvalidShare(reason=msg)
92 if update_share_status: 92 ↛ 96line 92 didn't jump to line 96 because the condition on line 92 was always true
93 share_utils.notify_about_share_usage(context, share_ref,
94 share_instance,
95 "transfer.delete.start")
96 self.db.transfer_destroy(context, transfer_id,
97 update_share_status=update_share_status)
98 if update_share_status: 98 ↛ 102line 98 didn't jump to line 102 because the condition on line 98 was always true
99 share_utils.notify_about_share_usage(context, share_ref,
100 share_instance,
101 "transfer.delete.end")
102 LOG.info('Transfer %s has been deleted successful.', transfer_id)
104 def get_all(self, context, limit=None, sort_key=None,
105 sort_dir=None, filters=None, offset=None):
106 filters = filters or {}
107 all_tenants = strutils.bool_from_string(filters.pop('all_tenants',
108 'false'))
109 query_by_project = False
111 if all_tenants:
112 try:
113 policy.check_policy(context, 'share_transfer',
114 'get_all_tenant')
115 except exception.PolicyNotAuthorized:
116 query_by_project = True
117 else:
118 query_by_project = True
120 if query_by_project:
121 transfers = self.db.transfer_get_all_by_project(
122 context, context.project_id,
123 limit=limit, sort_key=sort_key, sort_dir=sort_dir,
124 filters=filters, offset=offset)
125 else:
126 transfers = self.db.transfer_get_all(context,
127 limit=limit,
128 sort_key=sort_key,
129 sort_dir=sort_dir,
130 filters=filters,
131 offset=offset)
133 return transfers
135 def _get_random_string(self, length):
136 """Get a random hex string of the specified length."""
137 rndstr = ""
139 # Note that the string returned by this function must contain only
140 # characters that the recipient can enter on their keyboard. The
141 # function sha256().hexdigit() achieves this by generating a hash
142 # which will only contain hexadecimal digits.
143 while len(rndstr) < length:
144 rndstr += hashlib.sha256(os.urandom(255)).hexdigest()
146 return rndstr[0:length]
148 def _get_crypt_hash(self, salt, auth_key):
149 """Generate a random hash based on the salt and the auth key."""
150 def _format_str(input_str):
151 if not isinstance(input_str, (bytes, str)): 151 ↛ 152line 151 didn't jump to line 152 because the condition on line 151 was never true
152 input_str = str(input_str)
153 if isinstance(input_str, str): 153 ↛ 155line 153 didn't jump to line 155 because the condition on line 153 was always true
154 input_str = input_str.encode('utf-8')
155 return input_str
156 salt = _format_str(salt)
157 auth_key = _format_str(auth_key)
158 return hmac.new(salt, auth_key, hashlib.sha256).hexdigest()
160 def create(self, context, share_id, display_name):
161 """Creates an entry in the transfers table."""
162 LOG.debug("Generating transfer record for share %s", share_id)
163 try:
164 share_ref = self.share_api.get(context, share_id)
165 except exception.NotFound:
166 msg = _("Share specified was not found.")
167 raise exception.InvalidShare(reason=msg)
168 policy.check_policy(context, "share_transfer", "create",
169 target_obj=share_ref)
170 share_instance = share_ref['instance']
172 mount_point_name = share_instance['mount_point_name']
173 if (mount_point_name and
174 mount_point_name.startswith(share_ref['project_id'])):
175 msg = _('Share %s has a custom mount_point_name %s.'
176 ' This has the project_id encoded in it.'
177 ' Transferring such'
178 ' a share isn\'t supported') % (share_ref['name'],
179 mount_point_name)
180 raise exception.Invalid(reason=msg)
182 if share_ref['status'] != "available":
183 raise exception.InvalidShare(reason=_("Share's status must be "
184 "available"))
185 if share_ref['share_network_id']:
186 raise exception.InvalidShare(reason=_(
187 "Shares exported over share networks cannot be transferred."))
188 if share_ref['share_group_id']: 188 ↛ 189line 188 didn't jump to line 189 because the condition on line 188 was never true
189 raise exception.InvalidShare(reason=_(
190 "Shares within share groups cannot be transferred."))
192 if share_ref.has_replicas: 192 ↛ 193line 192 didn't jump to line 193 because the condition on line 192 was never true
193 raise exception.InvalidShare(reason=_(
194 "Shares with replicas cannot be transferred."))
196 snapshots = self.db.share_snapshot_get_all_for_share(context, share_id)
197 for snapshot in snapshots:
198 if snapshot['status'] != "available": 198 ↛ 199line 198 didn't jump to line 199 because the condition on line 198 was never true
199 msg = _("Snapshot: %s status must be "
200 "available") % snapshot['id']
201 raise exception.InvalidSnapshot(reason=msg)
203 share_utils.notify_about_share_usage(context, share_ref,
204 share_instance,
205 "transfer.create.start")
206 # The salt is just a short random string.
207 salt = self._get_random_string(CONF.share_transfer_salt_length)
208 auth_key = self._get_random_string(CONF.share_transfer_key_length)
209 crypt_hash = self._get_crypt_hash(salt, auth_key)
211 transfer_rec = {'resource_type': constants.SHARE_RESOURCE_TYPE,
212 'resource_id': share_id,
213 'display_name': display_name,
214 'salt': salt,
215 'crypt_hash': crypt_hash,
216 'expires_at': None,
217 'source_project_id': share_ref['project_id']}
219 try:
220 transfer = self.db.transfer_create(context, transfer_rec)
221 except Exception:
222 with excutils.save_and_reraise_exception():
223 LOG.error("Failed to create transfer record for %s", share_id)
224 share_utils.notify_about_share_usage(context, share_ref,
225 share_instance,
226 "transfer.create.end")
227 return {'id': transfer['id'],
228 'resource_type': transfer['resource_type'],
229 'resource_id': transfer['resource_id'],
230 'display_name': transfer['display_name'],
231 'auth_key': auth_key,
232 'created_at': transfer['created_at'],
233 'source_project_id': transfer['source_project_id'],
234 'destination_project_id': transfer['destination_project_id'],
235 'accepted': transfer['accepted'],
236 'expires_at': transfer['expires_at']}
238 def _handle_snapshot_quota(self, context, snapshots, donor_id):
239 snapshots_num = len(snapshots)
240 share_snap_sizes = 0
241 for snapshot in snapshots:
242 share_snap_sizes += snapshot['size']
243 try:
244 reserve_opts = {'snapshots': snapshots_num,
245 'gigabytes': share_snap_sizes}
246 reservations = QUOTAS.reserve(context, **reserve_opts)
247 except exception.OverQuota as e:
248 reservations = None
249 overs = e.kwargs['overs']
250 usages = e.kwargs['usages']
251 quotas = e.kwargs['quotas']
253 def _consumed(name):
254 return (usages[name]['reserved'] + usages[name]['in_use'])
256 if 'snapshot_gigabytes' in overs:
257 msg = ("Quota exceeded for %(s_pid)s, tried to accept "
258 "%(s_size)sG snapshot (%(d_consumed)dG of "
259 "%(d_quota)dG already consumed).")
260 LOG.warning(msg, {
261 's_pid': context.project_id,
262 's_size': share_snap_sizes,
263 'd_consumed': _consumed('snapshot_gigabytes'),
264 'd_quota': quotas['snapshot_gigabytes']})
265 raise exception.SnapshotSizeExceedsAvailableQuota()
266 elif 'snapshots' in overs: 266 ↛ 277line 266 didn't jump to line 277 because the condition on line 266 was always true
267 msg = ("Quota exceeded for %(s_pid)s, tried to accept "
268 "%(s_num)s snapshot (%(d_consumed)d of "
269 "%(d_quota)d already consumed).")
270 LOG.warning(msg, {'s_pid': context.project_id,
271 's_num': snapshots_num,
272 'd_consumed': _consumed('snapshots'),
273 'd_quota': quotas['snapshots']})
274 raise exception.SnapshotLimitExceeded(
275 allowed=quotas['snapshots'])
277 try:
278 reserve_opts = {'snapshots': -snapshots_num,
279 'gigabytes': -share_snap_sizes}
280 donor_reservations = QUOTAS.reserve(context,
281 project_id=donor_id,
282 **reserve_opts)
283 except exception.OverQuota:
284 donor_reservations = None
285 LOG.exception("Failed to update share providing snapshots quota:"
286 " Over quota.")
288 return reservations, donor_reservations
290 @staticmethod
291 def _check_share_type_access(context, share_type_id, share_id):
292 share_type = share_types.get_share_type(
293 context, share_type_id, expected_fields=['projects'])
294 if not share_type['is_public']:
295 if context.project_id not in share_type['projects']: 295 ↛ exitline 295 didn't return from function '_check_share_type_access' because the condition on line 295 was always true
296 msg = _("Share type of share %(share_id)s is not public, "
297 "and current project can not access the share "
298 "type ") % {'share_id': share_id}
299 LOG.error(msg)
300 raise exception.InvalidShare(reason=msg)
302 def _check_transferred_project_quota(self, context, share_ref_size):
303 try:
304 reserve_opts = {'shares': 1, 'gigabytes': share_ref_size}
305 reservations = QUOTAS.reserve(context,
306 **reserve_opts)
307 except exception.OverQuota as exc:
308 reservations = None
309 self.share_api.check_if_share_quotas_exceeded(context, exc,
310 share_ref_size)
311 return reservations
313 @staticmethod
314 def _check_donor_project_quota(context, donor_id, share_ref_size,
315 transfer_id):
316 try:
317 reserve_opts = {'shares': -1, 'gigabytes': -share_ref_size}
318 donor_reservations = QUOTAS.reserve(context.elevated(),
319 project_id=donor_id,
320 **reserve_opts)
321 except Exception:
322 donor_reservations = None
323 LOG.exception("Failed to update quota donating share"
324 " transfer id %s", transfer_id)
325 return donor_reservations
327 @staticmethod
328 def _check_snapshot_status(snapshots, transfer_id):
329 for snapshot in snapshots:
330 # Only check snapshot with instances
331 if snapshot.get('status'): 331 ↛ 329line 331 didn't jump to line 329 because the condition on line 331 was always true
332 if snapshot['status'] != 'available': 332 ↛ 333line 332 didn't jump to line 333 because the condition on line 332 was never true
333 msg = (_('Transfer %(transfer_id)s: Snapshot '
334 '%(snapshot_id)s is not in the expected '
335 'available state.')
336 % {'transfer_id': transfer_id,
337 'snapshot_id': snapshot['id']})
338 LOG.error(msg)
339 raise exception.InvalidSnapshot(reason=msg)
341 def accept(self, context, transfer_id, auth_key, clear_rules=False):
342 """Accept a share that has been offered for transfer."""
343 # We must use an elevated context to make sure we can find the
344 # transfer.
345 transfer = self.db.transfer_get(context.elevated(), transfer_id)
347 crypt_hash = self._get_crypt_hash(transfer['salt'], auth_key)
348 if crypt_hash != transfer['crypt_hash']:
349 msg = (_("Attempt to transfer %s with invalid auth key.") %
350 transfer_id)
351 LOG.error(msg)
352 raise exception.InvalidAuthKey(reason=msg)
354 share_id = transfer['resource_id']
355 try:
356 # We must use an elevated context to see the share that is still
357 # owned by the donor.
358 share_ref = self.share_api.get(context.elevated(), share_id)
359 except exception.NotFound:
360 msg = _("Share specified was not found.")
361 raise exception.InvalidShare(reason=msg)
362 share_instance = share_ref['instance']
363 if share_ref['status'] != constants.STATUS_AWAITING_TRANSFER:
364 msg = (_('Transfer %(transfer_id)s: share id %(share_id)s '
365 'expected in awaiting_transfer state.')
366 % {'transfer_id': transfer_id, 'share_id': share_id})
367 LOG.error(msg)
368 raise exception.InvalidShare(reason=msg)
369 share_ref_size = share_ref['size']
370 share_type_id = share_ref.get('share_type_id')
371 # check share type access
372 if share_type_id: 372 ↛ 376line 372 didn't jump to line 376 because the condition on line 372 was always true
373 self._check_share_type_access(context, share_type_id, share_id)
375 # check per share quota limit
376 self.share_api.check_is_share_size_within_per_share_quota_limit(
377 context, share_ref_size)
379 # check accept transferred project quotas
380 reservations = self._check_transferred_project_quota(
381 context, share_ref_size)
383 # check donor project quotas
384 donor_id = share_ref['project_id']
385 donor_reservations = self._check_donor_project_quota(
386 context, donor_id, share_ref_size, transfer_id)
388 snap_res = None
389 snap_donor_res = None
390 accept_snapshots = False
391 snapshots = self.db.share_snapshot_get_all_for_share(
392 context.elevated(), share_id)
393 if snapshots:
394 self._check_snapshot_status(snapshots, transfer_id)
395 accept_snapshots = True
396 snap_res, snap_donor_res = self._handle_snapshot_quota(
397 context, snapshots, share_ref['project_id'])
399 share_utils.notify_about_share_usage(context, share_ref,
400 share_instance,
401 "transfer.accept.start")
402 try:
403 self.share_api.transfer_accept(context,
404 share_ref,
405 context.user_id,
406 context.project_id,
407 clear_rules=clear_rules)
408 # Transfer ownership of the share now, must use an elevated
409 # context.
410 self.db.transfer_accept(context.elevated(),
411 transfer_id,
412 context.user_id,
413 context.project_id,
414 accept_snapshots=accept_snapshots)
415 if reservations: 415 ↛ 417line 415 didn't jump to line 417 because the condition on line 415 was always true
416 QUOTAS.commit(context, reservations)
417 if snap_res: 417 ↛ 418line 417 didn't jump to line 418 because the condition on line 417 was never true
418 QUOTAS.commit(context, snap_res)
419 if donor_reservations: 419 ↛ 421line 419 didn't jump to line 421 because the condition on line 419 was always true
420 QUOTAS.commit(context, donor_reservations, project_id=donor_id)
421 if snap_donor_res: 421 ↛ 422line 421 didn't jump to line 422 because the condition on line 421 was never true
422 QUOTAS.commit(context, snap_donor_res, project_id=donor_id)
423 LOG.info("share %s has been transferred.", share_id)
424 except Exception:
425 with excutils.save_and_reraise_exception():
426 try:
427 # storage try to rollback
428 self.share_api.transfer_accept(context,
429 share_ref,
430 share_ref['user_id'],
431 share_ref['project_id'])
432 # db try to rollback
433 self.db.transfer_accept_rollback(
434 context.elevated(), transfer_id,
435 share_ref['user_id'], share_ref['project_id'],
436 rollback_snap=accept_snapshots)
437 finally:
438 if reservations:
439 QUOTAS.rollback(context, reservations)
440 if snap_res:
441 QUOTAS.rollback(context, snap_res)
442 if donor_reservations:
443 QUOTAS.rollback(context, donor_reservations,
444 project_id=donor_id)
445 if snap_donor_res:
446 QUOTAS.rollback(context, snap_donor_res,
447 project_id=donor_id)
449 share_utils.notify_about_share_usage(context, share_ref,
450 share_instance,
451 "transfer.accept.end")