Coverage for manila/api/v2/share_snapshots.py: 91%
369 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 2013 NetApp
2# Copyright 2015 EMC Corporation.
3# All Rights Reserved.
4#
5# Licensed under the Apache License, Version 2.0 (the "License"); you may
6# not use this file except in compliance with the License. You may obtain
7# a copy of the License at
8#
9# http://www.apache.org/licenses/LICENSE-2.0
10#
11# Unless required by applicable law or agreed to in writing, software
12# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
13# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
14# License for the specific language governing permissions and limitations
15# under the License.
17"""The share snapshots api."""
19import ast
20from http import client as http_client
22from oslo_log import log
23import webob
24from webob import exc
26from manila.api import common
27from manila.api.openstack import api_version_request as api_version
28from manila.api.openstack import wsgi
29from manila.api.schemas import share_snapshots as schema
30from manila.api.v2 import metadata
31from manila.api import validation
32from manila.api.views import share_snapshots as snapshot_views
33from manila.common import constants
34from manila import db
35from manila.db import api as db_api
36from manila import exception
37from manila.i18n import _
38from manila import policy
39from manila import share
40from manila import utils
42LOG = log.getLogger(__name__)
45class ShareSnapshotMixin:
46 """Mixin class for Share Snapshot Controllers."""
48 def _update(self, *args, **kwargs):
49 db.share_snapshot_update(*args, **kwargs)
51 def _get(self, *args, **kwargs):
52 return self.share_api.get_snapshot(*args, **kwargs)
54 def _delete(self, *args, **kwargs):
55 return self.share_api.delete_snapshot(*args, **kwargs)
57 def show(self, req, id):
58 """Return data about the given snapshot."""
59 context = req.environ['manila.context']
61 try:
62 snapshot = self.share_api.get_snapshot(context, id)
64 # Snapshot with no instances is filtered out.
65 if snapshot.get('status') is None:
66 raise exc.HTTPNotFound()
67 except exception.NotFound:
68 raise exc.HTTPNotFound()
70 return self._view_builder.detail(req, snapshot)
72 def delete(self, req, id):
73 """Delete a snapshot."""
74 context = req.environ['manila.context']
76 LOG.info("Delete snapshot with id: %s", id, context=context)
77 policy.check_policy(context, 'share', 'delete_snapshot')
79 try:
80 snapshot = self.share_api.get_snapshot(context, id)
81 self.share_api.delete_snapshot(context, snapshot)
82 except exception.NotFound:
83 raise exc.HTTPNotFound()
84 return webob.Response(status_int=http_client.ACCEPTED)
86 def index(self, req):
87 """Returns a summary list of snapshots."""
88 req.GET.pop('name~', None)
89 req.GET.pop('description~', None)
90 req.GET.pop('description', None)
91 return self._get_snapshots(req, is_detail=False)
93 def detail(self, req):
94 """Returns a detailed list of snapshots."""
95 req.GET.pop('name~', None)
96 req.GET.pop('description~', None)
97 req.GET.pop('description', None)
98 return self._get_snapshots(req, is_detail=True)
100 def _get_snapshots(self, req, is_detail):
101 """Returns a list of snapshots."""
102 context = req.environ['manila.context']
104 search_opts = {}
105 search_opts.update(req.GET)
106 params = common.get_pagination_params(req)
107 limit, offset = [params.get('limit'), params.get('offset')]
109 # Remove keys that are not related to share attrs
110 search_opts.pop('limit', None)
111 search_opts.pop('offset', None)
113 show_count = False
114 if 'with_count' in search_opts:
115 show_count = utils.get_bool_from_api_params(
116 'with_count', search_opts)
117 search_opts.pop('with_count')
119 sort_key, sort_dir = common.get_sort_params(search_opts)
120 key_dict = {"name": "display_name",
121 "description": "display_description"}
122 for key in key_dict:
123 if sort_key == key: 123 ↛ 124line 123 didn't jump to line 124 because the condition on line 123 was never true
124 sort_key = key_dict[key]
126 # NOTE(vponomaryov): Manila stores in DB key 'display_name', but
127 # allows to use both keys 'name' and 'display_name'. It is leftover
128 # from Cinder v1 and v2 APIs.
129 if 'name' in search_opts:
130 search_opts['display_name'] = search_opts.pop('name')
131 if 'description' in search_opts: 131 ↛ 132line 131 didn't jump to line 132 because the condition on line 131 was never true
132 search_opts['display_description'] = search_opts.pop(
133 'description')
135 # Deserialize dicts
136 if req.api_version_request >= api_version.APIVersionRequest("2.73"):
137 if 'metadata' in search_opts:
138 try:
139 search_opts['metadata'] = ast.literal_eval(
140 search_opts['metadata'])
141 except ValueError:
142 msg = _('Invalid value for metadata filter.')
143 raise webob.exc.HTTPBadRequest(explanation=msg)
144 else:
145 search_opts.pop('metadata', None)
147 # like filter
148 for key, db_key in (('name~', 'display_name~'),
149 ('description~', 'display_description~')):
150 if key in search_opts: 150 ↛ 151line 150 didn't jump to line 151 because the condition on line 150 was never true
151 search_opts[db_key] = search_opts.pop(key)
153 common.remove_invalid_options(context, search_opts,
154 self._get_snapshots_search_options())
156 total_count = None
157 if show_count:
158 count, snapshots = self.share_api.get_all_snapshots_with_count(
159 context, search_opts=search_opts, limit=limit, offset=offset,
160 sort_key=sort_key, sort_dir=sort_dir)
161 total_count = count
162 else:
163 snapshots = self.share_api.get_all_snapshots(
164 context, search_opts=search_opts, limit=limit, offset=offset,
165 sort_key=sort_key, sort_dir=sort_dir)
167 if is_detail:
168 snapshots = self._view_builder.detail_list(
169 req, snapshots, total_count)
170 else:
171 snapshots = self._view_builder.summary_list(
172 req, snapshots, total_count)
173 return snapshots
175 def _get_snapshots_search_options(self):
176 """Return share snapshot search options allowed by non-admin."""
177 return ('display_name', 'status', 'share_id', 'size', 'display_name~',
178 'display_description~', 'display_description', 'metadata')
180 def update(self, req, id, body):
181 """Update a snapshot."""
182 context = req.environ['manila.context']
183 policy.check_policy(context, 'share', 'snapshot_update')
185 if not body or 'snapshot' not in body: 185 ↛ 186line 185 didn't jump to line 186 because the condition on line 185 was never true
186 raise exc.HTTPUnprocessableEntity()
188 snapshot_data = body['snapshot']
189 valid_update_keys = (
190 'display_name',
191 'display_description',
192 )
194 update_dict = {key: snapshot_data[key]
195 for key in valid_update_keys
196 if key in snapshot_data}
198 common.check_display_field_length(
199 update_dict.get('display_name'), 'display_name')
200 common.check_display_field_length(
201 update_dict.get('display_description'), 'display_description')
203 try:
204 snapshot = self.share_api.get_snapshot(context, id)
205 except exception.NotFound:
206 raise exc.HTTPNotFound()
208 snapshot = self.share_api.snapshot_update(context, snapshot,
209 update_dict)
210 snapshot.update(update_dict)
211 return self._view_builder.detail(req, snapshot)
213 @wsgi.response(202)
214 def create(self, req, body):
215 """Creates a new snapshot."""
216 context = req.environ['manila.context']
218 if not self.is_valid_body(body, 'snapshot'):
219 raise exc.HTTPUnprocessableEntity()
221 snapshot = body['snapshot']
223 share_id = snapshot['share_id']
224 share = self.share_api.get(context, share_id)
226 # Verify that share can be snapshotted
227 if not share['snapshot_support']:
228 msg = _("Snapshots cannot be created for share '%s' "
229 "since it does not have that capability.") % share_id
230 LOG.error(msg)
231 raise exc.HTTPUnprocessableEntity(explanation=msg)
233 # we do not allow soft delete share with snapshot, and also
234 # do not allow create snapshot for shares in recycle bin,
235 # since it will lead to auto delete share failed.
236 if share['is_soft_deleted']:
237 msg = _("Snapshots cannot be created for share '%s' "
238 "since it has been soft deleted.") % share_id
239 raise exc.HTTPForbidden(explanation=msg)
241 LOG.info("Create snapshot from share %s",
242 share_id, context=context)
244 # NOTE(rushiagr): v2 API allows name instead of display_name
245 if 'name' in snapshot: 245 ↛ 253line 245 didn't jump to line 253 because the condition on line 245 was always true
246 snapshot['display_name'] = snapshot.get('name')
247 common.check_display_field_length(
248 snapshot['display_name'], 'name')
249 del snapshot['name']
251 # NOTE(rushiagr): v2 API allows description instead of
252 # display_description
253 if 'description' in snapshot: 253 ↛ 259line 253 didn't jump to line 259 because the condition on line 253 was always true
254 snapshot['display_description'] = snapshot.get('description')
255 common.check_display_field_length(
256 snapshot['display_description'], 'description')
257 del snapshot['description']
259 kwargs = {}
260 if req.api_version_request >= api_version.APIVersionRequest("2.73"): 260 ↛ 261line 260 didn't jump to line 261 because the condition on line 260 was never true
261 if snapshot.get('metadata'):
262 metadata = snapshot.get('metadata')
263 kwargs.update({
264 'metadata': metadata,
265 })
267 new_snapshot = self.share_api.create_snapshot(
268 context,
269 share,
270 snapshot.get('display_name'),
271 snapshot.get('display_description'),
272 **kwargs)
273 return self._view_builder.detail(
274 req, dict(new_snapshot.items()))
277class ShareSnapshotsController(
278 ShareSnapshotMixin,
279 wsgi.Controller,
280 metadata.MetadataController,
281 wsgi.AdminActionsMixin,
282):
283 """The Share Snapshots API V2 controller for the OpenStack API."""
285 resource_name = 'share_snapshot'
286 _view_builder_class = snapshot_views.ViewBuilder
288 def __init__(self):
289 super().__init__()
290 self.share_api = share.API()
292 @wsgi.Controller.authorize('unmanage_snapshot')
293 def _unmanage(self, req, id, body=None, allow_dhss_true=False):
294 """Unmanage a share snapshot."""
295 context = req.environ['manila.context']
297 LOG.info("Unmanage share snapshot with id: %s.", id)
299 try:
300 snapshot = self.share_api.get_snapshot(context, id)
302 share = self.share_api.get(context, snapshot['share_id'])
303 if not allow_dhss_true and share.get('share_server_id'):
304 msg = _("Operation 'unmanage_snapshot' is not supported for "
305 "snapshots of shares that are created with share"
306 " servers (created with share-networks).")
307 raise exc.HTTPForbidden(explanation=msg)
308 elif share.get('has_replicas'):
309 msg = _("Share %s has replicas. Snapshots of this share "
310 "cannot currently be unmanaged until all replicas "
311 "are removed.") % share['id']
312 raise exc.HTTPConflict(explanation=msg)
313 elif snapshot['status'] in constants.TRANSITIONAL_STATUSES:
314 msg = _("Snapshot with transitional state cannot be "
315 "unmanaged. Snapshot '%(s_id)s' is in '%(state)s' "
316 "state.") % {'state': snapshot['status'],
317 's_id': snapshot['id']}
318 raise exc.HTTPForbidden(explanation=msg)
320 self.share_api.unmanage_snapshot(context, snapshot, share['host'])
321 except (exception.ShareSnapshotNotFound, exception.ShareNotFound) as e:
322 raise exc.HTTPNotFound(explanation=e.msg)
324 return webob.Response(status_int=http_client.ACCEPTED)
326 @wsgi.Controller.authorize('manage_snapshot')
327 def _manage(self, req, body):
328 """Instruct Manila to manage an existing snapshot.
330 Required HTTP Body:
332 .. code-block:: json
334 {
335 "snapshot":
336 {
337 "share_id": <Manila share id>,
338 "provider_location": <A string parameter that identifies
339 the snapshot on the backend>
340 }
341 }
343 Optional elements in 'snapshot' are:
344 name A name for the new snapshot.
345 description A description for the new snapshot.
346 driver_options Driver specific dicts for the existing snapshot.
347 """
349 context = req.environ['manila.context']
350 snapshot_data = self._validate_manage_parameters(context, body)
352 # NOTE(vponomaryov): compatibility actions are required between API and
353 # DB layers for 'name' and 'description' API params that are
354 # represented in DB as 'display_name' and 'display_description'
355 # appropriately.
356 name = snapshot_data.get('display_name',
357 snapshot_data.get('name'))
358 description = snapshot_data.get(
359 'display_description', snapshot_data.get('description'))
361 share_id = snapshot_data['share_id']
362 snapshot = {
363 'share_id': share_id,
364 'provider_location': snapshot_data['provider_location'],
365 'display_name': name,
366 'display_description': description,
367 }
368 if req.api_version_request >= api_version.APIVersionRequest("2.73"): 368 ↛ 369line 368 didn't jump to line 369 because the condition on line 368 was never true
369 if snapshot_data.get('metadata'):
370 metadata = snapshot_data.get('metadata')
371 snapshot.update({
372 'metadata': metadata,
373 })
375 try:
376 share_ref = self.share_api.get(context, share_id)
377 except exception.NotFound:
378 raise exception.ShareNotFound(share_id=share_id)
379 if share_ref.get('is_soft_deleted'):
380 msg = _("Can not manage snapshot for share '%s' "
381 "since it has been soft deleted.") % share_id
382 raise exc.HTTPForbidden(explanation=msg)
384 driver_options = snapshot_data.get('driver_options', {})
386 try:
387 snapshot_ref = self.share_api.manage_snapshot(context, snapshot,
388 driver_options,
389 share=share_ref)
390 except (exception.ShareNotFound, exception.ShareSnapshotNotFound) as e:
391 raise exc.HTTPNotFound(explanation=e.msg)
392 except (exception.InvalidShare,
393 exception.ManageInvalidShareSnapshot) as e:
394 raise exc.HTTPConflict(explanation=e.msg)
396 return self._view_builder.detail(req, snapshot_ref)
398 def _validate_manage_parameters(self, context, body):
399 if not (body and self.is_valid_body(body, 'snapshot')):
400 msg = _("Snapshot entity not found in request body.")
401 raise exc.HTTPUnprocessableEntity(explanation=msg)
403 data = body['snapshot']
405 required_parameters = ('share_id', 'provider_location')
406 self._validate_parameters(data, required_parameters)
408 return data
410 def _validate_parameters(self, data, required_parameters,
411 fix_response=False):
413 if fix_response:
414 exc_response = exc.HTTPBadRequest
415 else:
416 exc_response = exc.HTTPUnprocessableEntity
418 for parameter in required_parameters:
419 if parameter not in data:
420 msg = _("Required parameter %s not found.") % parameter
421 raise exc_response(explanation=msg)
422 if not data.get(parameter):
423 msg = _("Required parameter %s is empty.") % parameter
424 raise exc_response(explanation=msg)
425 if not isinstance(data[parameter], str):
426 msg = _("Parameter %s must be a string.") % parameter
427 raise exc_response(explanation=msg)
429 def _check_if_share_share_network_is_active(self, context, snapshot):
430 share_network_id = snapshot['share'].get('share_network_id')
431 if share_network_id: 431 ↛ 432line 431 didn't jump to line 432 because the condition on line 431 was never true
432 share_network = db_api.share_network_get(
433 context, share_network_id)
434 common.check_share_network_is_active(share_network)
436 def _allow(self, req, id, body, enable_ipv6=False):
437 context = req.environ['manila.context']
439 if not (body and self.is_valid_body(body, 'allow_access')):
440 msg = _("Access data not found in request body.")
441 raise exc.HTTPBadRequest(explanation=msg)
443 access_data = body.get('allow_access')
445 required_parameters = ('access_type', 'access_to')
446 self._validate_parameters(access_data, required_parameters,
447 fix_response=True)
449 access_type = access_data['access_type']
450 access_to = access_data['access_to']
452 common.validate_access(access_type=access_type,
453 access_to=access_to,
454 enable_ipv6=enable_ipv6)
456 snapshot = self.share_api.get_snapshot(context, id)
458 self._check_if_share_share_network_is_active(context, snapshot)
460 self._check_mount_snapshot_support(context, snapshot)
462 try:
463 access = self.share_api.snapshot_allow_access(
464 context, snapshot, access_type, access_to)
465 except exception.ShareSnapshotAccessExists as e:
466 raise webob.exc.HTTPBadRequest(explanation=e.msg)
468 return self._view_builder.detail_access(req, access)
470 def _deny(self, req, id, body):
471 context = req.environ['manila.context']
473 if not (body and self.is_valid_body(body, 'deny_access')):
474 msg = _("Access data not found in request body.")
475 raise exc.HTTPBadRequest(explanation=msg)
477 access_data = body.get('deny_access')
479 self._validate_parameters(
480 access_data, ('access_id',), fix_response=True)
482 access_id = access_data['access_id']
484 snapshot = self.share_api.get_snapshot(context, id)
486 self._check_mount_snapshot_support(context, snapshot)
488 self._check_if_share_share_network_is_active(context, snapshot)
490 access = self.share_api.snapshot_access_get(context, access_id)
492 if access['share_snapshot_id'] != snapshot['id']:
493 msg = _("Access rule provided is not associated with given"
494 " snapshot.")
495 raise webob.exc.HTTPBadRequest(explanation=msg)
497 self.share_api.snapshot_deny_access(context, snapshot, access)
498 return webob.Response(status_int=http_client.ACCEPTED)
500 def _check_mount_snapshot_support(self, context, snapshot):
501 share = self.share_api.get(context, snapshot['share_id'])
502 if not share['mount_snapshot_support']:
503 msg = _("Cannot control access to the snapshot %(snap)s since the "
504 "parent share %(share)s does not support mounting its "
505 "snapshots.") % {'snap': snapshot['id'],
506 'share': share['id']}
507 raise exc.HTTPBadRequest(explanation=msg)
509 def _access_list(self, req, snapshot_id):
510 context = req.environ['manila.context']
512 snapshot = self.share_api.get_snapshot(context, snapshot_id)
513 self._check_mount_snapshot_support(context, snapshot)
514 access_list = self.share_api.snapshot_access_get_all(context, snapshot)
516 return self._view_builder.detail_list_access(req, access_list)
518 @wsgi.Controller.api_version('2.0', '2.6')
519 @wsgi.action('os-reset_status')
520 def snapshot_reset_status_legacy(self, req, id, body):
521 return self._reset_status(req, id, body)
523 @wsgi.Controller.api_version('2.7')
524 @wsgi.action('reset_status')
525 def snapshot_reset_status(self, req, id, body):
526 return self._reset_status(req, id, body)
528 @wsgi.Controller.api_version('2.0', '2.6')
529 @wsgi.action('os-force_delete')
530 def snapshot_force_delete_legacy(self, req, id, body):
531 return self._force_delete(req, id, body)
533 @wsgi.Controller.api_version('2.7')
534 @wsgi.action('force_delete')
535 def snapshot_force_delete(self, req, id, body):
536 return self._force_delete(req, id, body)
538 @wsgi.Controller.api_version('2.12')
539 @wsgi.response(202)
540 def manage(self, req, body):
541 return self._manage(req, body)
543 @wsgi.Controller.api_version('2.12', '2.48')
544 @wsgi.action('unmanage')
545 def unmanage(self, req, id, body=None):
546 return self._unmanage(req, id, body)
548 @wsgi.Controller.api_version('2.49') # noqa
549 @wsgi.action('unmanage')
550 def unmanage(self, req, id, # pylint: disable=function-redefined # noqa F811
551 body=None):
552 return self._unmanage(req, id, body, allow_dhss_true=True)
554 @wsgi.Controller.api_version('2.32')
555 @wsgi.action('allow_access')
556 @wsgi.response(202)
557 @wsgi.Controller.authorize
558 def allow_access(self, req, id, body=None):
559 enable_ipv6 = False
560 if req.api_version_request >= api_version.APIVersionRequest("2.38"):
561 enable_ipv6 = True
562 return self._allow(req, id, body, enable_ipv6)
564 @wsgi.Controller.api_version('2.32')
565 @wsgi.action('deny_access')
566 @wsgi.Controller.authorize
567 def deny_access(self, req, id, body=None):
568 return self._deny(req, id, body)
570 @wsgi.Controller.api_version('2.32')
571 @wsgi.Controller.authorize
572 def access_list(self, req, snapshot_id):
573 return self._access_list(req, snapshot_id)
575 @wsgi.Controller.api_version("2.0")
576 @validation.request_query_schema(schema.index_request_query, "2.0", "2.35")
577 @validation.request_query_schema(
578 schema.index_request_query_v236, "2.36", "2.72")
579 @validation.request_query_schema(
580 schema.index_request_query_v273, "2.73", "2.78")
581 @validation.request_query_schema(schema.index_request_query_v279, "2.79")
582 @validation.response_body_schema(schema.index_response_body)
583 def index(self, req):
584 """Returns a summary list of shares."""
585 if req.api_version_request < api_version.APIVersionRequest("2.36"):
586 req.GET.pop('name~', None)
587 req.GET.pop('description~', None)
588 req.GET.pop('description', None)
590 if req.api_version_request < api_version.APIVersionRequest("2.79"):
591 req.GET.pop('with_count', None)
593 return self._get_snapshots(req, is_detail=False)
595 @wsgi.Controller.api_version("2.0")
596 def detail(self, req):
597 """Returns a detailed list of shares."""
598 if req.api_version_request < api_version.APIVersionRequest("2.36"): 598 ↛ 602line 598 didn't jump to line 602 because the condition on line 598 was always true
599 req.GET.pop('name~', None)
600 req.GET.pop('description~', None)
601 req.GET.pop('description', None)
602 return self._get_snapshots(req, is_detail=True)
604 @wsgi.Controller.api_version("2.73")
605 @wsgi.Controller.authorize("get_metadata")
606 def index_metadata(self, req, resource_id):
607 """Returns the list of metadata for a given share snapshot."""
608 return self._index_metadata(req, resource_id)
610 @wsgi.Controller.api_version("2.73")
611 @wsgi.Controller.authorize("update_metadata")
612 def create_metadata(self, req, resource_id, body):
613 return self._create_metadata(req, resource_id, body)
615 @wsgi.Controller.api_version("2.73")
616 @wsgi.Controller.authorize("update_metadata")
617 def update_all_metadata(self, req, resource_id, body):
618 return self._update_all_metadata(req, resource_id, body)
620 @wsgi.Controller.api_version("2.73")
621 @wsgi.Controller.authorize("update_metadata")
622 def update_metadata_item(self, req, resource_id, body, key):
623 return self._update_metadata_item(req, resource_id, body, key)
625 @wsgi.Controller.api_version("2.73")
626 @wsgi.Controller.authorize("get_metadata")
627 def show_metadata(self, req, resource_id, key):
628 return self._show_metadata(req, resource_id, key)
630 @wsgi.Controller.api_version("2.73")
631 @wsgi.Controller.authorize("delete_metadata")
632 def delete_metadata(self, req, resource_id, key):
633 return self._delete_metadata(req, resource_id, key)
636def create_resource():
637 return wsgi.Resource(ShareSnapshotsController())