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

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. 

15 

16""" 

17Handles all requests relating to transferring ownership of shares. 

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 

28from oslo_utils import strutils 

29 

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 

39 

40 

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] 

53 

54CONF = cfg.CONF 

55CONF.register_opts(share_transfer_opts) 

56 

57LOG = logging.getLogger(__name__) 

58QUOTAS = quota.QUOTAS 

59 

60 

61class API(base.Base): 

62 """API for interacting share transfers.""" 

63 

64 def __init__(self): 

65 self.share_api = share_api.API() 

66 super().__init__() 

67 

68 def get(self, context, transfer_id): 

69 transfer = self.db.transfer_get(context, transfer_id) 

70 return transfer 

71 

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) 

103 

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 

110 

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 

119 

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) 

132 

133 return transfers 

134 

135 def _get_random_string(self, length): 

136 """Get a random hex string of the specified length.""" 

137 rndstr = "" 

138 

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() 

145 

146 return rndstr[0:length] 

147 

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() 

159 

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'] 

171 

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) 

181 

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.")) 

191 

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.")) 

195 

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) 

202 

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) 

210 

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']} 

218 

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']} 

237 

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'] 

252 

253 def _consumed(name): 

254 return (usages[name]['reserved'] + usages[name]['in_use']) 

255 

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']) 

276 

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.") 

287 

288 return reservations, donor_reservations 

289 

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) 

301 

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 

312 

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 

326 

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) 

340 

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) 

346 

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) 

353 

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) 

374 

375 # check per share quota limit 

376 self.share_api.check_is_share_size_within_per_share_quota_limit( 

377 context, share_ref_size) 

378 

379 # check accept transferred project quotas 

380 reservations = self._check_transferred_project_quota( 

381 context, share_ref_size) 

382 

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) 

387 

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']) 

398 

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) 

448 

449 share_utils.notify_about_share_usage(context, share_ref, 

450 share_instance, 

451 "transfer.accept.end")