Update micversion to API2.69, Manila share support Recycle Bin

Add support share Recycle Bin, the end user can soft delete
share to Recycle Bin, and can restore the share within 7 days,
otherwise the share will be deleted automatically.

DocImpact
APIImpact
Partially-Implements: blueprint manila-share-support-recycle-bin

Change-Id: Ic838eec5fea890be6513514053329b1d2d86b3ba
This commit is contained in:
haixin 2021-07-14 16:34:28 +08:00 committed by haixin
parent 7c04fcb904
commit d51eb05c05
37 changed files with 1138 additions and 25 deletions

View File

@ -326,6 +326,15 @@ is_public_query:
in: query in: query
required: false required: false
type: boolean type: boolean
is_soft_deleted_query:
description: |
A boolean query parameter that, when set to True, will return all shares
in recycle bin. Default is False, will return all shares not in recycle
bin.
in: query
required: false
type: boolean
min_version: 2.69
limit: limit:
description: | description: |
The maximum number of shares to return. The maximum number of shares to return.
@ -1390,6 +1399,13 @@ is_public_shares_response:
in: body in: body
required: true required: true
type: boolean type: boolean
is_soft_deleted_response:
description: |
Whether the share has been soft deleted to recycle bin or not.
in: body
required: false
type: boolean
min_version: 2.69
links: links:
description: | description: |
Pagination and bookmark links for the resource. Pagination and bookmark links for the resource.
@ -2321,6 +2337,14 @@ revert_to_snapshot_support_share_capability:
required: true required: true
type: boolean type: boolean
min_version: 2.27 min_version: 2.27
scheduled_to_be_deleted_at_response:
description: |
Estimated time at which the share in the recycle bin will be deleted
automatically.
in: body
required: false
type: string
min_version: 2.69
scheduler_hints: scheduler_hints:
description: | description: |
One or more scheduler_hints key and value pairs as a dictionary of One or more scheduler_hints key and value pairs as a dictionary of

View File

@ -0,0 +1,3 @@
{
"restore": null
}

View File

@ -0,0 +1,3 @@
{
"soft_delete": null
}

View File

@ -500,3 +500,97 @@ Request example
.. literalinclude:: samples/share-actions-revert-to-snapshot-request.json .. literalinclude:: samples/share-actions-revert-to-snapshot-request.json
:language: javascript :language: javascript
Soft delete share (since API v2.69)
===================================
.. rest_method:: POST /v2/shares/{share_id}/action
.. versionadded:: 2.69
Soft delete a share to recycle bin.
Preconditions
- Share status must be ``available``, ``error`` or ``inactive``
- Share can't have any snapshot.
- Share can't have a share group snapshot.
- Share can't have dependent replicas.
- You cannot soft delete share that already is in the Recycle Bin..
- You cannot soft delete a share that doesn't belong to your project.
- You cannot soft delete a share is busy with an active task.
Response codes
--------------
.. rest_status_code:: success status.yaml
- 202
.. rest_status_code:: error status.yaml
- 400
- 401
- 403
- 404
- 409
Request
-------
.. rest_parameters:: parameters.yaml
- project_id: project_id_path
- share_id: share_id
Request example
---------------
.. literalinclude:: samples/share-actions-soft-delete-request.json
:language: javascript
Restore share (since API v2.69)
===============================
.. rest_method:: POST /v2/shares/{share_id}/action
.. versionadded:: 2.69
Restore a share from recycle bin.
Response codes
--------------
.. rest_status_code:: success status.yaml
- 202
.. rest_status_code:: error status.yaml
- 401
- 403
- 404
Request
-------
.. rest_parameters:: parameters.yaml
- project_id: project_id_path
- share_id: share_id
Request example
---------------
.. literalinclude:: samples/share-actions-restore-request.json
:language: javascript

View File

@ -130,6 +130,7 @@ Request
- name~: name_inexact_query - name~: name_inexact_query
- description~: description_inexact_query - description~: description_inexact_query
- with_count: with_count_query - with_count: with_count_query
- is_soft_deleted: is_soft_deleted_query
- limit: limit - limit: limit
- offset: offset - offset: offset
- sort_key: sort_key - sort_key: sort_key
@ -198,6 +199,7 @@ Request
- name~: name_inexact_query - name~: name_inexact_query
- description~: description_inexact_query - description~: description_inexact_query
- with_count: with_count_query - with_count: with_count_query
- is_soft_deleted: is_soft_deleted_query
- limit: limit - limit: limit
- offset: offset - offset: offset
- sort_key: sort_key - sort_key: sort_key
@ -242,6 +244,8 @@ Response parameters
- volume_type: volume_type_shares_response - volume_type: volume_type_shares_response
- export_location: export_location - export_location: export_location
- export_locations: export_locations - export_locations: export_locations
- is_soft_deleted: is_soft_deleted_response
- scheduled_to_be_deleted_at: scheduled_to_be_deleted_at_response
Response example Response example

View File

@ -170,19 +170,25 @@ REST_API_VERSION_HISTORY = """
actions on the share network's endpoint: actions on the share network's endpoint:
'update_security_service', 'update_security_service_check' and 'update_security_service', 'update_security_service_check' and
'add_security_service_check'. 'add_security_service_check'.
* 2.64 - Added 'force' field to extend share api, which can extend share
directly without validation through share scheduler.
* 2.65 - Added ability to set affinity scheduler hints via the share * 2.65 - Added ability to set affinity scheduler hints via the share
create API. create API.
* 2.66 - Added filter search by group spec for share group type list. * 2.66 - Added filter search by group spec for share group type list.
* 2.67 - Added ability to set 'only_host' scheduler hint for the share * 2.67 - Added ability to set 'only_host' scheduler hint for the share
create and share replica create API. create and share replica create API.
* 2.68 - Added admin only capabilities to share metadata API * 2.68 - Added admin only capabilities to share metadata API
* 2.69 - Added new share action to soft delete share to recycle bin or
restore share from recycle bin. Also, a new parameter called
`is_soft_deleted` was added so users can filter out
shares in the recycle bin while listing shares.
""" """
# The minimum and maximum versions of the API supported # The minimum and maximum versions of the API supported
# The default api version request is defined to be the # The default api version request is defined to be the
# minimum version of the API supported. # minimum version of the API supported.
_MIN_API_VERSION = "2.0" _MIN_API_VERSION = "2.0"
_MAX_API_VERSION = "2.68" _MAX_API_VERSION = "2.69"
DEFAULT_API_VERSION = _MIN_API_VERSION DEFAULT_API_VERSION = _MIN_API_VERSION

View File

@ -376,4 +376,11 @@ ____
2.68 2.68
---- ----
Added admin only capabilities to share metadata API Added admin only capabilities to share metadata API.
2.69
----
Manila support Recycle Bin. Soft delete share to Recycle Bin: ``POST
/v2/shares/{share_id}/action {"soft_delete": null}``. List shares in
Recycle Bin: `` GET /v2/shares?is_soft_deleted=true``. Restore share from
Recycle Bin: `` POST /v2/shares/{share_id}/action {'restore': null}``.

View File

@ -190,6 +190,14 @@ class ShareSnapshotMixin(object):
LOG.error(msg) LOG.error(msg)
raise exc.HTTPUnprocessableEntity(explanation=msg) raise exc.HTTPUnprocessableEntity(explanation=msg)
# we do not allow soft delete share with snapshot, and also
# do not allow create snapshot for shares in recycle bin,
# since it will lead to auto delete share failed.
if share['is_soft_deleted']:
msg = _("Snapshots cannot be created for share '%s' "
"since it has been soft deleted.") % share_id
raise exc.HTTPForbidden(explanation=msg)
LOG.info("Create snapshot from share %s", LOG.info("Create snapshot from share %s",
share_id, context=context) share_id, context=context)

View File

@ -38,6 +38,10 @@ class ShareUnmanageMixin(object):
try: try:
share = self.share_api.get(context, id) share = self.share_api.get(context, id)
if share.get('is_soft_deleted'):
msg = _("Share '%s cannot be unmanaged, "
"since it has been soft deleted.") % share['id']
raise exc.HTTPForbidden(explanation=msg)
if share.get('has_replicas'): if share.get('has_replicas'):
msg = _("Share %s has replicas. It cannot be unmanaged " msg = _("Share %s has replicas. It cannot be unmanaged "
"until all replicas are removed.") % share['id'] "until all replicas are removed.") % share['id']

View File

@ -135,6 +135,11 @@ class ShareMixin(object):
'with_count', search_opts) 'with_count', search_opts)
search_opts.pop('with_count') search_opts.pop('with_count')
if 'is_soft_deleted' in search_opts:
is_soft_deleted = utils.get_bool_from_api_params(
'is_soft_deleted', search_opts)
search_opts['is_soft_deleted'] = is_soft_deleted
# Deserialize dicts # Deserialize dicts
if 'metadata' in search_opts: if 'metadata' in search_opts:
search_opts['metadata'] = ast.literal_eval(search_opts['metadata']) search_opts['metadata'] = ast.literal_eval(search_opts['metadata'])
@ -192,7 +197,7 @@ class ShareMixin(object):
'is_public', 'metadata', 'extra_specs', 'sort_key', 'sort_dir', 'is_public', 'metadata', 'extra_specs', 'sort_key', 'sort_dir',
'share_group_id', 'share_group_snapshot_id', 'export_location_id', 'share_group_id', 'share_group_snapshot_id', 'export_location_id',
'export_location_path', 'display_name~', 'display_description~', 'export_location_path', 'display_name~', 'display_description~',
'display_description', 'limit', 'offset') 'display_description', 'limit', 'offset', 'is_soft_deleted')
@wsgi.Controller.authorize @wsgi.Controller.authorize
def update(self, req, id, body): def update(self, req, id, body):
@ -218,6 +223,11 @@ class ShareMixin(object):
except exception.NotFound: except exception.NotFound:
raise exc.HTTPNotFound() raise exc.HTTPNotFound()
if share.get('is_soft_deleted'):
msg = _("Share '%s cannot be updated, "
"since it has been soft deleted.") % share['id']
raise exc.HTTPForbidden(explanation=msg)
update_dict = common.validate_public_share_policy( update_dict = common.validate_public_share_policy(
context, update_dict, api='update') context, update_dict, api='update')
@ -443,6 +453,10 @@ class ShareMixin(object):
access_data.pop('metadata', None) access_data.pop('metadata', None)
share = self.share_api.get(context, id) share = self.share_api.get(context, id)
if share.get('is_soft_deleted'):
msg = _("Cannot allow access for share '%s' "
"since it has been soft deleted.") % id
raise exc.HTTPForbidden(explanation=msg)
share_network_id = share.get('share_network_id') share_network_id = share.get('share_network_id')
if share_network_id: if share_network_id:
share_network = db.share_network_get(context, share_network_id) share_network = db.share_network_get(context, share_network_id)
@ -490,6 +504,12 @@ class ShareMixin(object):
'deny_access', body.get('os-deny_access'))['access_id'] 'deny_access', body.get('os-deny_access'))['access_id']
share = self.share_api.get(context, id) share = self.share_api.get(context, id)
if share.get('is_soft_deleted'):
msg = _("Cannot deny access for share '%s' "
"since it has been soft deleted.") % id
raise exc.HTTPForbidden(explanation=msg)
share_network_id = share.get('share_network_id', None) share_network_id = share.get('share_network_id', None)
if share_network_id: if share_network_id:
@ -521,6 +541,11 @@ class ShareMixin(object):
share, size, force = self._get_valid_extend_parameters( share, size, force = self._get_valid_extend_parameters(
context, id, body, 'os-extend') context, id, body, 'os-extend')
if share.get('is_soft_deleted'):
msg = _("Cannot extend share '%s' "
"since it has been soft deleted.") % id
raise exc.HTTPForbidden(explanation=msg)
try: try:
self.share_api.extend(context, share, size, force=force) self.share_api.extend(context, share, size, force=force)
except (exception.InvalidInput, exception.InvalidShare) as e: except (exception.InvalidInput, exception.InvalidShare) as e:
@ -536,6 +561,11 @@ class ShareMixin(object):
share, size = self._get_valid_shrink_parameters( share, size = self._get_valid_shrink_parameters(
context, id, body, 'os-shrink') context, id, body, 'os-shrink')
if share.get('is_soft_deleted'):
msg = _("Cannot shrink share '%s' "
"since it has been soft deleted.") % id
raise exc.HTTPForbidden(explanation=msg)
try: try:
self.share_api.shrink(context, share, size) self.share_api.shrink(context, share, size)
except (exception.InvalidInput, exception.InvalidShare) as e: except (exception.InvalidInput, exception.InvalidShare) as e:

View File

@ -21,6 +21,7 @@ from manila.api.views import share_instance as instance_view
from manila import db from manila import db
from manila import exception from manila import exception
from manila import share from manila import share
from manila import utils
class ShareInstancesController(wsgi.Controller, wsgi.AdminActionsMixin): class ShareInstancesController(wsgi.Controller, wsgi.AdminActionsMixin):
@ -72,7 +73,7 @@ class ShareInstancesController(wsgi.Controller, wsgi.AdminActionsMixin):
instances = db.share_instances_get_all(context) instances = db.share_instances_get_all(context)
return self._view_builder.detail_list(req, instances) return self._view_builder.detail_list(req, instances)
@wsgi.Controller.api_version("2.35") # noqa @wsgi.Controller.api_version("2.35", "2.68") # noqa
@wsgi.Controller.authorize @wsgi.Controller.authorize
def index(self, req): # pylint: disable=function-redefined # noqa F811 def index(self, req): # pylint: disable=function-redefined # noqa F811
context = req.environ['manila.context'] context = req.environ['manila.context']
@ -84,6 +85,23 @@ class ShareInstancesController(wsgi.Controller, wsgi.AdminActionsMixin):
instances = db.share_instances_get_all(context, filters) instances = db.share_instances_get_all(context, filters)
return self._view_builder.detail_list(req, instances) return self._view_builder.detail_list(req, instances)
@wsgi.Controller.api_version("2.69") # noqa
@wsgi.Controller.authorize
def index(self, req): # pylint: disable=function-redefined # noqa F811
context = req.environ['manila.context']
filters = {}
filters.update(req.GET)
common.remove_invalid_options(
context, filters, ('export_location_id', 'export_location_path',
'is_soft_deleted'))
if 'is_soft_deleted' in filters:
is_soft_deleted = utils.get_bool_from_api_params(
'is_soft_deleted', filters)
filters['is_soft_deleted'] = is_soft_deleted
instances = db.share_instances_get_all(context, filters)
return self._view_builder.detail_list(req, instances)
@wsgi.Controller.api_version("2.3") @wsgi.Controller.api_version("2.3")
@wsgi.Controller.authorize @wsgi.Controller.authorize
def show(self, req, id): def show(self, req, id):

View File

@ -169,6 +169,11 @@ class ShareReplicationController(wsgi.Controller, wsgi.AdminActionsMixin):
msg = _("No share exists with ID %s.") msg = _("No share exists with ID %s.")
raise exc.HTTPNotFound(explanation=msg % share_id) raise exc.HTTPNotFound(explanation=msg % share_id)
if share_ref.get('is_soft_deleted'):
msg = _("Replica cannot be created for share '%s' "
"since it has been soft deleted.") % share_id
raise exc.HTTPForbidden(explanation=msg)
share_network_id = share_ref.get('share_network_id', None) share_network_id = share_ref.get('share_network_id', None)
if share_network_id: if share_network_id:

View File

@ -116,18 +116,29 @@ class ShareSnapshotsController(share_snapshots.ShareSnapshotMixin,
description = snapshot_data.get( description = snapshot_data.get(
'display_description', snapshot_data.get('description')) 'display_description', snapshot_data.get('description'))
share_id = snapshot_data['share_id']
snapshot = { snapshot = {
'share_id': snapshot_data['share_id'], 'share_id': share_id,
'provider_location': snapshot_data['provider_location'], 'provider_location': snapshot_data['provider_location'],
'display_name': name, 'display_name': name,
'display_description': description, 'display_description': description,
} }
try:
share_ref = self.share_api.get(context, share_id)
except exception.NotFound:
raise exception.ShareNotFound(share_id=share_id)
if share_ref.get('is_soft_deleted'):
msg = _("Can not manage snapshot for share '%s' "
"since it has been soft deleted.") % share_id
raise exc.HTTPForbidden(explanation=msg)
driver_options = snapshot_data.get('driver_options', {}) driver_options = snapshot_data.get('driver_options', {})
try: try:
snapshot_ref = self.share_api.manage_snapshot(context, snapshot, snapshot_ref = self.share_api.manage_snapshot(context, snapshot,
driver_options) driver_options,
share=share_ref)
except (exception.ShareNotFound, exception.ShareSnapshotNotFound) as e: except (exception.ShareNotFound, exception.ShareSnapshotNotFound) as e:
raise exc.HTTPNotFound(explanation=e.msg) raise exc.HTTPNotFound(explanation=e.msg)
except (exception.InvalidShare, except (exception.InvalidShare,

View File

@ -66,6 +66,11 @@ class ShareController(shares.ShareMixin,
share = self.share_api.get(context, share_id) share = self.share_api.get(context, share_id)
snapshot = self.share_api.get_snapshot(context, snapshot_id) snapshot = self.share_api.get_snapshot(context, snapshot_id)
if share.get('is_soft_deleted'):
msg = _("Share '%s cannot revert to snapshot, "
"since it has been soft deleted.") % share_id
raise exc.HTTPForbidden(explanation=msg)
# Ensure share supports reverting to a snapshot # Ensure share supports reverting to a snapshot
if not share['revert_to_snapshot_support']: if not share['revert_to_snapshot_support']:
msg_args = {'share_id': share_id, 'snap_id': snapshot_id} msg_args = {'share_id': share_id, 'snap_id': snapshot_id}
@ -219,11 +224,29 @@ class ShareController(shares.ShareMixin,
@wsgi.Controller.api_version('2.0', '2.6') @wsgi.Controller.api_version('2.0', '2.6')
@wsgi.action('os-reset_status') @wsgi.action('os-reset_status')
def share_reset_status_legacy(self, req, id, body): def share_reset_status_legacy(self, req, id, body):
context = req.environ['manila.context']
try:
share = self.share_api.get(context, id)
except exception.NotFound:
raise exception.ShareNotFound(share_id=id)
if share.get('is_soft_deleted'):
msg = _("status cannot be reset for share '%s' "
"since it has been soft deleted.") % id
raise exc.HTTPForbidden(explanation=msg)
return self._reset_status(req, id, body) return self._reset_status(req, id, body)
@wsgi.Controller.api_version('2.7') @wsgi.Controller.api_version('2.7')
@wsgi.action('reset_status') @wsgi.action('reset_status')
def share_reset_status(self, req, id, body): def share_reset_status(self, req, id, body):
context = req.environ['manila.context']
try:
share = self.share_api.get(context, id)
except exception.NotFound:
raise exception.ShareNotFound(share_id=id)
if share.get('is_soft_deleted'):
msg = _("status cannot be reset for share '%s' "
"since it has been soft deleted.") % id
raise exc.HTTPForbidden(explanation=msg)
return self._reset_status(req, id, body) return self._reset_status(req, id, body)
@wsgi.Controller.api_version('2.0', '2.6') @wsgi.Controller.api_version('2.0', '2.6')
@ -236,6 +259,60 @@ class ShareController(shares.ShareMixin,
def share_force_delete(self, req, id, body): def share_force_delete(self, req, id, body):
return self._force_delete(req, id, body) return self._force_delete(req, id, body)
@wsgi.Controller.api_version('2.69')
@wsgi.action('soft_delete')
def share_soft_delete(self, req, id, body):
"""Soft delete a share."""
context = req.environ['manila.context']
LOG.debug("Soft delete share with id: %s", id, context=context)
try:
share = self.share_api.get(context, id)
self.share_api.soft_delete(context, share)
except exception.NotFound:
raise exc.HTTPNotFound()
except exception.InvalidShare as e:
raise exc.HTTPForbidden(explanation=e.msg)
except exception.ShareBusyException as e:
raise exc.HTTPForbidden(explanation=e.msg)
except exception.Conflict as e:
raise exc.HTTPConflict(explanation=e.msg)
return webob.Response(status_int=http_client.ACCEPTED)
@wsgi.Controller.api_version('2.69')
@wsgi.action('restore')
def share_restore(self, req, id, body):
"""Restore a share from recycle bin."""
context = req.environ['manila.context']
LOG.debug("Restore share with id: %s", id, context=context)
try:
share = self.share_api.get(context, id)
except exception.NotFound:
msg = _("No share exists with ID %s.")
raise exc.HTTPNotFound(explanation=msg % id)
# If the share not exist in Recycle Bin, the API will return
# success directly.
is_soft_deleted = share.get('is_soft_deleted')
if not is_soft_deleted:
return webob.Response(status_int=http_client.OK)
# If the share has reached the expired time, and is been deleting,
# it too late to restore the share.
if share['status'] in [constants.STATUS_DELETING,
constants.STATUS_ERROR_DELETING]:
msg = _("Share %s is being deleted or error deleted, "
"cannot be restore.")
raise exc.HTTPForbidden(explanation=msg % id)
self.share_api.restore(context, share)
return webob.Response(status_int=http_client.ACCEPTED)
@wsgi.Controller.api_version('2.29', experimental=True) @wsgi.Controller.api_version('2.29', experimental=True)
@wsgi.action("migration_start") @wsgi.action("migration_start")
@wsgi.Controller.authorize @wsgi.Controller.authorize
@ -247,6 +324,12 @@ class ShareController(shares.ShareMixin,
except exception.NotFound: except exception.NotFound:
msg = _("Share %s not found.") % id msg = _("Share %s not found.") % id
raise exc.HTTPNotFound(explanation=msg) raise exc.HTTPNotFound(explanation=msg)
if share.get('is_soft_deleted'):
msg = _("Migration cannot start for share '%s' "
"since it has been soft deleted.") % id
raise exception.InvalidShare(reason=msg)
params = body.get('migration_start') params = body.get('migration_start')
if not params: if not params:
@ -355,6 +438,15 @@ class ShareController(shares.ShareMixin,
@wsgi.action("reset_task_state") @wsgi.action("reset_task_state")
@wsgi.Controller.authorize @wsgi.Controller.authorize
def reset_task_state(self, req, id, body): def reset_task_state(self, req, id, body):
context = req.environ['manila.context']
try:
share = self.share_api.get(context, id)
except exception.NotFound:
raise exception.ShareNotFound(share_id=id)
if share.get('is_soft_deleted'):
msg = _("task state cannot be reset for share '%s' "
"since it has been soft deleted.") % id
raise exc.HTTPForbidden(explanation=msg)
return self._reset_status(req, id, body, status_attr='task_state') return self._reset_status(req, id, body, status_attr='task_state')
@wsgi.Controller.api_version('2.0', '2.6') @wsgi.Controller.api_version('2.0', '2.6')
@ -482,6 +574,9 @@ class ShareController(shares.ShareMixin,
if req.api_version_request < api_version.APIVersionRequest("2.42"): if req.api_version_request < api_version.APIVersionRequest("2.42"):
req.GET.pop('with_count', None) req.GET.pop('with_count', None)
if req.api_version_request < api_version.APIVersionRequest("2.69"):
req.GET.pop('is_soft_deleted', None)
return self._get_shares(req, is_detail=False) return self._get_shares(req, is_detail=False)
@wsgi.Controller.api_version("2.0") @wsgi.Controller.api_version("2.0")
@ -496,6 +591,9 @@ class ShareController(shares.ShareMixin,
req.GET.pop('description~', None) req.GET.pop('description~', None)
req.GET.pop('description', None) req.GET.pop('description', None)
if req.api_version_request < api_version.APIVersionRequest("2.69"):
req.GET.pop('is_soft_deleted', None)
return self._get_shares(req, is_detail=True) return self._get_shares(req, is_detail=True)

View File

@ -36,6 +36,7 @@ class ViewBuilder(common.ViewBuilder):
"add_mount_snapshot_support_field", "add_mount_snapshot_support_field",
"add_progress_field", "add_progress_field",
"translate_creating_from_snapshot_status", "translate_creating_from_snapshot_status",
"add_share_recycle_bin_field",
] ]
def summary_list(self, request, shares, count=None): def summary_list(self, request, shares, count=None):
@ -197,3 +198,9 @@ class ViewBuilder(common.ViewBuilder):
@common.ViewBuilder.versioned_method("2.54") @common.ViewBuilder.versioned_method("2.54")
def add_progress_field(self, context, share_dict, share): def add_progress_field(self, context, share_dict, share):
share_dict['progress'] = share.get('progress') share_dict['progress'] = share.get('progress')
@common.ViewBuilder.versioned_method("2.69")
def add_share_recycle_bin_field(self, context, share_dict, share):
share_dict['is_soft_deleted'] = share.get('is_soft_deleted')
share_dict['scheduled_to_be_deleted_at'] = share.get(
'scheduled_to_be_deleted_at')

View File

@ -127,6 +127,11 @@ global_opts = [
help="Specify list of protocols to be allowed for share " help="Specify list of protocols to be allowed for share "
"creation. Available values are '%s'" % "creation. Available values are '%s'" %
list(constants.SUPPORTED_SHARE_PROTOCOLS)), list(constants.SUPPORTED_SHARE_PROTOCOLS)),
cfg.IntOpt('soft_deleted_share_retention_time',
default=604800,
help='Maximum time (in seconds) to keep a share in the recycle '
'bin, it will be deleted automatically after this amount '
'of time has elapsed.'),
] ]
CONF.register_opts(global_opts) CONF.register_opts(global_opts)

View File

@ -451,6 +451,15 @@ def share_get_all_by_share_server(context, share_server_id, filters=None,
sort_dir=sort_dir) sort_dir=sort_dir)
def get_shares_in_recycle_bin_by_share_server(
context, share_server_id, filters=None,
sort_key=None, sort_dir=None):
"""Returns all shares in recycle bin with given share server ID."""
return IMPL.get_shares_in_recycle_bin_by_share_server(
context, share_server_id, filters=filters, sort_key=sort_key,
sort_dir=sort_dir)
def share_get_all_by_share_server_with_count( def share_get_all_by_share_server_with_count(
context, share_server_id, filters=None, sort_key=None, sort_dir=None): context, share_server_id, filters=None, sort_key=None, sort_dir=None):
"""Returns all shares with given share server ID.""" """Returns all shares with given share server ID."""
@ -459,11 +468,29 @@ def share_get_all_by_share_server_with_count(
sort_dir=sort_dir) sort_dir=sort_dir)
def get_shares_in_recycle_bin_by_network(
context, share_network_id, filters=None,
sort_key=None, sort_dir=None):
"""Returns all shares in recycle bin with given share network ID."""
return IMPL.get_shares_in_recycle_bin_by_network(
context, share_network_id, filters=filters, sort_key=sort_key,
sort_dir=sort_dir)
def share_delete(context, share_id): def share_delete(context, share_id):
"""Delete share.""" """Delete share."""
return IMPL.share_delete(context, share_id) return IMPL.share_delete(context, share_id)
def share_soft_delete(context, share_id):
"""Soft delete share."""
return IMPL.share_soft_delete(context, share_id)
def share_restore(context, share_id):
"""Restore share."""
return IMPL.share_restore(context, share_id)
################### ###################
@ -1077,6 +1104,11 @@ def share_server_get_all_unused_deletable(context, host, updated_before):
updated_before) updated_before)
def get_all_expired_shares(context):
"""Get all expired share DB records."""
return IMPL.get_all_expired_shares(context)
def share_server_backend_details_set(context, share_server_id, server_details): def share_server_backend_details_set(context, share_server_id, server_details):
"""Create DB record with backend details.""" """Create DB record with backend details."""
return IMPL.share_server_backend_details_set(context, share_server_id, return IMPL.share_server_backend_details_set(context, share_server_id,

View File

@ -0,0 +1,56 @@
# 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.
"""add is_soft_deleted and scheduled_to_be_deleted_at to shares table
Revision ID: 1946cb97bb8d
Revises: fbdfabcba377
Create Date: 2021-07-14 14:41:58.615439
"""
# revision identifiers, used by Alembic.
revision = '1946cb97bb8d'
down_revision = 'fbdfabcba377'
from alembic import op
from oslo_log import log
import sqlalchemy as sa
LOG = log.getLogger(__name__)
def upgrade():
try:
op.add_column('shares', sa.Column(
'is_soft_deleted', sa.Boolean,
nullable=False, server_default=sa.sql.false()))
op.add_column('shares', sa.Column(
'scheduled_to_be_deleted_at', sa.DateTime))
except Exception:
LOG.error("Columns shares.is_soft_deleted "
"and/or shares.scheduled_to_be_deleted_at not created!")
raise
def downgrade():
try:
op.drop_column('shares', 'is_soft_deleted')
op.drop_column('shares', 'scheduled_to_be_deleted_at')
LOG.warning("All shares in recycle bin will automatically be "
"restored, need to be manually identified and deleted "
"again.")
except Exception:
LOG.error("Column shares.is_soft_deleted and/or "
"shares.scheduled_to_be_deleted_at not dropped!")
raise

View File

@ -47,6 +47,7 @@ from sqlalchemy import MetaData
from sqlalchemy import or_ from sqlalchemy import or_
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
from sqlalchemy.orm import subqueryload from sqlalchemy.orm import subqueryload
from sqlalchemy.sql.expression import false
from sqlalchemy.sql.expression import literal from sqlalchemy.sql.expression import literal
from sqlalchemy.sql.expression import true from sqlalchemy.sql.expression import true
from sqlalchemy.sql import func from sqlalchemy.sql import func
@ -1652,6 +1653,16 @@ def share_instances_get_all(context, filters=None, session=None):
models.ShareInstanceExportLocations.uuid == models.ShareInstanceExportLocations.uuid ==
export_location_id) export_location_id)
query = query.join(
models.Share,
models.Share.id ==
models.ShareInstance.share_id)
is_soft_deleted = filters.get('is_soft_deleted')
if is_soft_deleted:
query = query.filter(models.Share.is_soft_deleted == true())
else:
query = query.filter(models.Share.is_soft_deleted == false())
instance_ids = filters.get('instance_ids') instance_ids = filters.get('instance_ids')
if instance_ids: if instance_ids:
query = query.filter(models.ShareInstance.id.in_(instance_ids)) query = query.filter(models.ShareInstance.id.in_(instance_ids))
@ -1987,7 +1998,7 @@ def _process_share_filters(query, filters, project_id=None, is_public=False):
if filters is None: if filters is None:
filters = {} filters = {}
share_filter_keys = ['share_group_id', 'snapshot_id'] share_filter_keys = ['share_group_id', 'snapshot_id', 'is_soft_deleted']
instance_filter_keys = ['share_server_id', 'status', 'share_type_id', instance_filter_keys = ['share_server_id', 'status', 'share_type_id',
'host', 'share_network_id'] 'host', 'share_network_id']
share_filters = {} share_filters = {}
@ -2196,6 +2207,11 @@ def _share_get_all_with_filters(context, project_id=None, share_server_id=None,
if share_server_id: if share_server_id:
filters['share_server_id'] = share_server_id filters['share_server_id'] = share_server_id
# if not specified is_soft_deleted filter, default is False, to get
# shares not in recycle bin.
if 'is_soft_deleted' not in filters:
filters['is_soft_deleted'] = False
query = _process_share_filters( query = _process_share_filters(
query, filters, project_id, is_public=is_public) query, filters, project_id, is_public=is_public)
@ -2228,6 +2244,25 @@ def _share_get_all_with_filters(context, project_id=None, share_server_id=None,
return query return query
@require_admin_context
def get_all_expired_shares(context):
query = (
_share_get_query(context).join(
models.ShareInstance,
models.ShareInstance.share_id == models.Share.id
)
)
filters = {"is_soft_deleted": True}
query = _process_share_filters(query, filters=filters)
scheduled_deleted_attr = getattr(models.Share,
'scheduled_to_be_deleted_at', None)
now_time = timeutils.utcnow()
query = query.filter(scheduled_deleted_attr.op('<=')(now_time))
result = query.all()
return result
@require_admin_context @require_admin_context
def share_get_all(context, filters=None, sort_key=None, sort_dir=None): def share_get_all(context, filters=None, sort_key=None, sort_dir=None):
project_id = filters.pop('project_id', None) if filters else None project_id = filters.pop('project_id', None) if filters else None
@ -2302,6 +2337,19 @@ def share_get_all_by_share_server(context, share_server_id, filters=None,
return query return query
@require_context
def get_shares_in_recycle_bin_by_share_server(
context, share_server_id, filters=None, sort_key=None, sort_dir=None):
"""Returns list of shares in recycle bin with given share server."""
if filters is None:
filters = {}
filters["is_soft_deleted"] = True
query = _share_get_all_with_filters(
context, share_server_id=share_server_id, filters=filters,
sort_key=sort_key, sort_dir=sort_dir)
return query
@require_context @require_context
def share_get_all_by_share_server_with_count( def share_get_all_by_share_server_with_count(
context, share_server_id, filters=None, sort_key=None, sort_dir=None): context, share_server_id, filters=None, sort_key=None, sort_dir=None):
@ -2312,6 +2360,19 @@ def share_get_all_by_share_server_with_count(
return count, query return count, query
@require_context
def get_shares_in_recycle_bin_by_network(
context, share_network_id, filters=None, sort_key=None, sort_dir=None):
"""Returns list of shares in recycle bin with given share network."""
if filters is None:
filters = {}
filters["share_network_id"] = share_network_id
filters["is_soft_deleted"] = True
query = _share_get_all_with_filters(context, filters=filters,
sort_key=sort_key, sort_dir=sort_dir)
return query
@require_context @require_context
def share_delete(context, share_id): def share_delete(context, share_id):
session = get_session() session = get_session()
@ -2330,6 +2391,40 @@ def share_delete(context, share_id):
filter_by(share_id=share_id).soft_delete()) filter_by(share_id=share_id).soft_delete())
@require_context
@oslo_db_api.wrap_db_retry(max_retries=5, retry_on_deadlock=True)
def share_soft_delete(context, share_id):
session = get_session()
now_time = timeutils.utcnow()
time_delta = datetime.timedelta(
seconds=CONF.soft_deleted_share_retention_time)
scheduled_to_be_deleted_at = now_time + time_delta
update_values = {
'is_soft_deleted': True,
'scheduled_to_be_deleted_at': scheduled_to_be_deleted_at
}
with session.begin():
share_ref = share_get(context, share_id, session=session)
share_ref.update(update_values)
share_ref.save(session=session)
@require_context
@oslo_db_api.wrap_db_retry(max_retries=5, retry_on_deadlock=True)
def share_restore(context, share_id):
session = get_session()
update_values = {
'is_soft_deleted': False,
'scheduled_to_be_deleted_at': None
}
with session.begin():
share_ref = share_get(context, share_id, session=session)
share_ref.update(update_values)
share_ref.save(session=session)
################### ###################

View File

@ -315,6 +315,8 @@ class Share(BASE, ManilaBase):
source_share_group_snapshot_member_id = Column(String(36), nullable=True) source_share_group_snapshot_member_id = Column(String(36), nullable=True)
task_state = Column(String(255)) task_state = Column(String(255))
is_soft_deleted = Column(Boolean, default=False)
scheduled_to_be_deleted_at = Column(DateTime)
instances = orm.relationship( instances = orm.relationship(
"ShareInstance", "ShareInstance",
lazy='subquery', lazy='subquery',

View File

@ -316,6 +316,30 @@ shares_policies = [
], ],
deprecated_rule=deprecated_share_delete deprecated_rule=deprecated_share_delete
), ),
policy.DocumentedRuleDefault(
name=BASE_POLICY_NAME % 'soft_delete',
check_str=base.SYSTEM_ADMIN_OR_PROJECT_MEMBER,
scope_types=['system', 'project'],
description="Soft Delete a share.",
operations=[
{
'method': 'POST',
'path': '/shares/{share_id}/action',
}
],
),
policy.DocumentedRuleDefault(
name=BASE_POLICY_NAME % 'restore',
check_str=base.SYSTEM_ADMIN_OR_PROJECT_MEMBER,
scope_types=['system', 'project'],
description="Restore a share.",
operations=[
{
'method': 'POST',
'path': '/shares/{share_id}/action',
}
],
),
policy.DocumentedRuleDefault( policy.DocumentedRuleDefault(
name=BASE_POLICY_NAME % 'force_delete', name=BASE_POLICY_NAME % 'force_delete',
check_str=base.SYSTEM_ADMIN_OR_PROJECT_ADMIN, check_str=base.SYSTEM_ADMIN_OR_PROJECT_ADMIN,

View File

@ -988,11 +988,14 @@ class API(base.Base):
# share server here, when manage/unmanage operations will be supported # share server here, when manage/unmanage operations will be supported
# for driver_handles_share_servers=True mode # for driver_handles_share_servers=True mode
def manage_snapshot(self, context, snapshot_data, driver_options): def manage_snapshot(self, context, snapshot_data, driver_options,
try: share=None):
share = self.db.share_get(context, snapshot_data['share_id']) if not share:
except exception.NotFound: try:
raise exception.ShareNotFound(share_id=snapshot_data['share_id']) share = self.db.share_get(context, snapshot_data['share_id'])
except exception.NotFound:
raise exception.ShareNotFound(
share_id=snapshot_data['share_id'])
if share['has_replicas']: if share['has_replicas']:
msg = (_("Share %s has replicas. Snapshots of this share cannot " msg = (_("Share %s has replicas. Snapshots of this share cannot "
@ -1158,6 +1161,52 @@ class API(base.Base):
self.share_rpcapi.revert_to_snapshot( self.share_rpcapi.revert_to_snapshot(
context, share, snapshot, active_replica['host'], reservations) context, share, snapshot, active_replica['host'], reservations)
@policy.wrap_check_policy('share')
def soft_delete(self, context, share):
"""Soft delete share."""
share_id = share['id']
if share['is_soft_deleted']:
msg = _("The share has been soft deleted already")
raise exception.InvalidShare(reason=msg)
statuses = (constants.STATUS_AVAILABLE, constants.STATUS_ERROR,
constants.STATUS_INACTIVE)
if share['status'] not in statuses:
msg = _("Share status must be one of %(statuses)s") % {
"statuses": statuses}
raise exception.InvalidShare(reason=msg)
# If the share has more than one replica,
# it can't be soft deleted until the additional replicas are removed.
if share.has_replicas:
msg = _("Share %s has replicas. Remove the replicas before "
"soft deleting the share.") % share_id
raise exception.Conflict(err=msg)
snapshots = self.db.share_snapshot_get_all_for_share(context, share_id)
if len(snapshots):
msg = _("Share still has %d dependent snapshots.") % len(snapshots)
raise exception.InvalidShare(reason=msg)
share_group_snapshot_members_count = (
self.db.count_share_group_snapshot_members_in_share(
context, share_id))
if share_group_snapshot_members_count:
msg = (
_("Share still has %d dependent share group snapshot "
"members.") % share_group_snapshot_members_count)
raise exception.InvalidShare(reason=msg)
self._check_is_share_busy(share)
self.db.share_soft_delete(context, share_id)
@policy.wrap_check_policy('share')
def restore(self, context, share):
"""Restore share."""
share_id = share['id']
self.db.share_restore(context, share_id)
@policy.wrap_check_policy('share') @policy.wrap_check_policy('share')
def delete(self, context, share, force=False): def delete(self, context, share, force=False):
"""Delete share.""" """Delete share."""
@ -1859,7 +1908,7 @@ class API(base.Base):
'display_description', 'display_description~', 'snapshot_id', 'display_description', 'display_description~', 'snapshot_id',
'status', 'share_type_id', 'project_id', 'export_location_id', 'status', 'share_type_id', 'project_id', 'export_location_id',
'export_location_path', 'limit', 'offset', 'host', 'export_location_path', 'limit', 'offset', 'host',
'share_network_id'] 'share_network_id', 'is_soft_deleted']
for key in filter_keys: for key in filter_keys:
if key in search_opts: if key in search_opts:
@ -2516,11 +2565,20 @@ class API(base.Base):
shares = self.db.share_get_all_by_share_server( shares = self.db.share_get_all_by_share_server(
context, share_server['id']) context, share_server['id'])
shares_in_recycle_bin = (
self.db.get_shares_in_recycle_bin_by_share_server(
context, share_server['id']))
if len(shares) == 0: if len(shares) == 0:
msg = _("Share server %s does not have shares." msg = _("Share server %s does not have shares."
% share_server['id']) % share_server['id'])
raise exception.InvalidShareServer(reason=msg) raise exception.InvalidShareServer(reason=msg)
if shares_in_recycle_bin:
msg = _("Share server %s has at least one share that has "
"been soft deleted." % share_server['id'])
raise exception.InvalidShareServer(reason=msg)
# We only handle "active" share servers for now # We only handle "active" share servers for now
if share_server['status'] != constants.STATUS_ACTIVE: if share_server['status'] != constants.STATUS_ACTIVE:
msg = _('Share server %(server_id)s status must be active, ' msg = _('Share server %(server_id)s status must be active, '
@ -2984,6 +3042,14 @@ class API(base.Base):
# Make sure the host is in the list of available hosts # Make sure the host is in the list of available hosts
utils.validate_service_host(admin_ctx, backend_host) utils.validate_service_host(admin_ctx, backend_host)
shares_in_recycle_bin = (
self.db.get_shares_in_recycle_bin_by_network(
context, share_network['id']))
if shares_in_recycle_bin:
msg = _("Some shares with share network %(sn_id)s have "
"been soft deleted.") % {'sn_id': share_network['id']}
raise exception.InvalidShareNetwork(reason=msg)
shares = self.get_all( shares = self.get_all(
context, search_opts={'share_network_id': share_network['id']}) context, search_opts={'share_network_id': share_network['id']})
shares_not_available = [ shares_not_available = [

View File

@ -131,6 +131,11 @@ share_manager_opts = [
default=False, default=False,
help='Offload pending share ensure during ' help='Offload pending share ensure during '
'share service startup'), 'share service startup'),
cfg.IntOpt('check_for_expired_shares_in_recycle_bin_interval',
default=3600,
help='This value, specified in seconds, determines how often '
'the share manager will check for expired shares and '
'delete them from the Recycle bin.'),
] ]
CONF = cfg.CONF CONF = cfg.CONF
@ -3483,6 +3488,17 @@ class ShareManager(manager.SchedulerDependentManager):
for server in servers: for server in servers:
self.delete_share_server(ctxt, server) self.delete_share_server(ctxt, server)
@periodic_task.periodic_task(
spacing=CONF.check_for_expired_shares_in_recycle_bin_interval)
@utils.require_driver_initialized
def delete_expired_share(self, ctxt):
LOG.debug("Check for expired share in recycle bin to delete.")
expired_shares = self.db.get_all_expired_shares(ctxt)
for share in expired_shares:
LOG.debug("share %s has expired, will be deleted", share['id'])
self.share_api.delete(ctxt, share, force=True)
@add_hooks @add_hooks
@utils.require_driver_initialized @utils.require_driver_initialized
def create_snapshot(self, context, share_id, snapshot_id): def create_snapshot(self, context, share_id, snapshot_id):

View File

@ -46,6 +46,7 @@ def stub_share(id, **kwargs):
'mount_snapshot_support': False, 'mount_snapshot_support': False,
'replication_type': None, 'replication_type': None,
'has_replicas': False, 'has_replicas': False,
'is_soft_deleted': False,
} }
share_instance = { share_instance = {
@ -149,6 +150,14 @@ def stub_share_delete(self, context, *args, **param):
pass pass
def stub_share_soft_delete(self, context, *args, **param):
pass
def stub_share_restore(self, context, *args, **param):
pass
def stub_share_update(self, context, *args, **param): def stub_share_update(self, context, *args, **param):
share = stub_share('1') share = stub_share('1')
return share return share

View File

@ -112,6 +112,29 @@ class ShareSnapshotAPITest(test.TestCase):
self.assertFalse(share_api.API.create_snapshot.called) self.assertFalse(share_api.API.create_snapshot.called)
def test_snapshot_create_in_recycle_bin(self):
self.mock_object(share_api.API, 'create_snapshot')
self.mock_object(
share_api.API,
'get',
mock.Mock(return_value={'snapshot_support': True,
'is_soft_deleted': True}))
body = {
'snapshot': {
'share_id': 200,
'force': False,
'name': 'fake_share_name',
'description': 'fake_share_description',
}
}
req = fakes.HTTPRequest.blank('/fake/snapshots')
self.assertRaises(
webob.exc.HTTPForbidden,
self.controller.create, req, body)
self.assertFalse(share_api.API.create_snapshot.called)
def test_snapshot_create_no_body(self): def test_snapshot_create_no_body(self):
body = {} body = {}
req = fakes.HTTPRequest.blank('/fake/snapshots') req = fakes.HTTPRequest.blank('/fake/snapshots')

View File

@ -121,6 +121,28 @@ class ShareUnmanageTest(test.TestCase):
self.mock_policy_check.assert_called_once_with( self.mock_policy_check.assert_called_once_with(
self.context, self.resource_name, 'unmanage') self.context, self.resource_name, 'unmanage')
def test_unmanage_share_that_has_been_soft_deleted(self):
share = dict(status=constants.STATUS_AVAILABLE, id='foo_id',
instance={}, is_soft_deleted=True)
mock_api_unmanage = self.mock_object(self.controller.share_api,
'unmanage')
mock_db_snapshots_get = self.mock_object(
self.controller.share_api.db, 'share_snapshot_get_all_for_share')
self.mock_object(
self.controller.share_api, 'get',
mock.Mock(return_value=share))
self.assertRaises(
webob.exc.HTTPForbidden,
self.controller.unmanage, self.request, share['id'])
self.assertFalse(mock_api_unmanage.called)
self.assertFalse(mock_db_snapshots_get.called)
self.controller.share_api.get.assert_called_once_with(
self.request.environ['manila.context'], share['id'])
self.mock_policy_check.assert_called_once_with(
self.context, self.resource_name, 'unmanage')
def test_unmanage_share_based_on_share_server(self): def test_unmanage_share_based_on_share_server(self):
share = dict(instance=dict(share_server_id='foo_id'), id='bar_id') share = dict(instance=dict(share_server_id='foo_id'), id='bar_id')
self.mock_object( self.mock_object(

View File

@ -67,19 +67,25 @@ class ShareInstancesAPITest(test.TestCase):
self.assertEqual([i['id'] for i in expected], self.assertEqual([i['id'] for i in expected],
[i['id'] for i in actual]) [i['id'] for i in actual])
@ddt.data("2.3", "2.34", "2.35") @ddt.data("2.3", "2.34", "2.35", "2.69")
def test_index(self, version): def test_index(self, version):
url = '/share_instances' url = '/share_instances'
if (api_version_request.APIVersionRequest(version) >= if (api_version_request.APIVersionRequest(version) >=
api_version_request.APIVersionRequest('2.35')): api_version_request.APIVersionRequest('2.35')):
url += "?export_location_path=/admin/export/location" url += "?export_location_path=/admin/export/location"
if (api_version_request.APIVersionRequest(version) >=
api_version_request.APIVersionRequest('2.69')):
url += "&is_soft_deleted=true"
req = self._get_request(url, version=version) req = self._get_request(url, version=version)
req_context = req.environ['manila.context'] req_context = req.environ['manila.context']
last_instance = [db_utils.create_share(size=1,
is_soft_deleted=True).instance]
share_instances_count = 3 share_instances_count = 3
test_instances = [ other_instances = [
db_utils.create_share(size=s + 1).instance db_utils.create_share(size=s + 1).instance
for s in range(0, share_instances_count) for s in range(0, share_instances_count)
] ]
test_instances = other_instances + last_instance
db.share_export_locations_update( db.share_export_locations_update(
self.admin_context, test_instances[0]['id'], self.admin_context, test_instances[0]['id'],
@ -88,8 +94,13 @@ class ShareInstancesAPITest(test.TestCase):
actual_result = self.controller.index(req) actual_result = self.controller.index(req)
if (api_version_request.APIVersionRequest(version) >= if (api_version_request.APIVersionRequest(version) >=
api_version_request.APIVersionRequest('2.69')):
test_instances = []
elif (api_version_request.APIVersionRequest(version) >=
api_version_request.APIVersionRequest('2.35')): api_version_request.APIVersionRequest('2.35')):
test_instances = test_instances[:1] test_instances = test_instances[:1]
else:
test_instances = other_instances
self._validate_ids_in_share_instances_list( self._validate_ids_in_share_instances_list(
test_instances, actual_result['share_instances']) test_instances, actual_result['share_instances'])
self.mock_policy_check.assert_called_once_with( self.mock_policy_check.assert_called_once_with(

View File

@ -375,6 +375,27 @@ class ShareReplicasApiTest(test.TestCase):
self.mock_policy_check.assert_called_once_with( self.mock_policy_check.assert_called_once_with(
self.member_context, self.resource_name, 'create') self.member_context, self.resource_name, 'create')
def test_create_has_been_soft_deleted(self):
share_ref = fake_share.fake_share(is_soft_deleted=True)
body = {
'share_replica': {
'share_id': 'FAKE_SHAREID',
'availability_zone': 'FAKE_AZ'
}
}
mock__view_builder_call = self.mock_object(
share_replicas.replication_view.ReplicationViewBuilder,
'detail_list')
self.mock_object(share_replicas.db, 'share_get',
mock.Mock(return_value=share_ref))
self.assertRaises(exc.HTTPForbidden,
self.controller.create,
self.replicas_req, body)
self.assertFalse(mock__view_builder_call.called)
self.mock_policy_check.assert_called_once_with(
self.member_context, self.resource_name, 'create')
@ddt.data(exception.AvailabilityZoneNotFound, @ddt.data(exception.AvailabilityZoneNotFound,
exception.ReplicationException, exception.ShareBusyException) exception.ReplicationException, exception.ShareBusyException)
def test_create_exception_path(self, exception_type): def test_create_exception_path(self, exception_type):

View File

@ -731,9 +731,14 @@ class ShareSnapshotAdminActionsAPITest(test.TestCase):
data['snapshot']['share_id'] = 'fake' data['snapshot']['share_id'] = 'fake'
data['snapshot']['provider_location'] = 'fake_volume_snapshot_id' data['snapshot']['provider_location'] = 'fake_volume_snapshot_id'
data['snapshot']['driver_options'] = {} data['snapshot']['driver_options'] = {}
return_share = fake_share.fake_share(is_soft_deleted=False,
id='fake')
return_snapshot = fake_share.fake_snapshot( return_snapshot = fake_share.fake_snapshot(
create_instance=True, id='fake_snap', create_instance=True, id='fake_snap',
provider_location='fake_volume_snapshot_id') provider_location='fake_volume_snapshot_id')
self.mock_object(
share_api.API, 'get', mock.Mock(
return_value=return_share))
self.mock_object( self.mock_object(
share_api.API, 'manage_snapshot', mock.Mock( share_api.API, 'manage_snapshot', mock.Mock(
return_value=return_snapshot)) return_value=return_snapshot))
@ -752,7 +757,8 @@ class ShareSnapshotAdminActionsAPITest(test.TestCase):
actual_snapshot = actual_result['snapshot'] actual_snapshot = actual_result['snapshot']
share_api.API.manage_snapshot.assert_called_once_with( share_api.API.manage_snapshot.assert_called_once_with(
mock.ANY, share_snapshot, data['snapshot']['driver_options']) mock.ANY, share_snapshot, data['snapshot']['driver_options'],
share=return_share)
self.assertEqual(return_snapshot['id'], self.assertEqual(return_snapshot['id'],
actual_result['snapshot']['id']) actual_result['snapshot']['id'])
self.assertEqual('fake_volume_snapshot_id', self.assertEqual('fake_volume_snapshot_id',
@ -781,6 +787,11 @@ class ShareSnapshotAdminActionsAPITest(test.TestCase):
body = get_fake_manage_body( body = get_fake_manage_body(
share_id='fake', provider_location='fake_volume_snapshot_id', share_id='fake', provider_location='fake_volume_snapshot_id',
driver_options={}) driver_options={})
return_share = fake_share.fake_share(is_soft_deleted=False,
id='fake')
self.mock_object(
share_api.API, 'get', mock.Mock(
return_value=return_share))
self.mock_object( self.mock_object(
share_api.API, 'manage_snapshot', mock.Mock( share_api.API, 'manage_snapshot', mock.Mock(
side_effect=exception_type)) side_effect=exception_type))
@ -798,6 +809,25 @@ class ShareSnapshotAdminActionsAPITest(test.TestCase):
self.manage_request.environ['manila.context'], self.manage_request.environ['manila.context'],
self.resource_name, 'manage_snapshot') self.resource_name, 'manage_snapshot')
def test_manage_share_has_been_soft_deleted(self):
self.mock_policy_check = self.mock_object(
policy, 'check_policy', mock.Mock(return_value=True))
body = get_fake_manage_body(
share_id='fake', provider_location='fake_volume_snapshot_id',
driver_options={})
return_share = fake_share.fake_share(is_soft_deleted=True,
id='fake')
self.mock_object(
share_api.API, 'get', mock.Mock(
return_value=return_share))
self.assertRaises(webob.exc.HTTPForbidden,
self.controller.manage,
self.manage_request, body)
self.mock_policy_check.assert_called_once_with(
self.manage_request.environ['manila.context'],
self.resource_name, 'manage_snapshot')
@ddt.data('1.0', '2.6', '2.11') @ddt.data('1.0', '2.6', '2.11')
def test_manage_version_not_found(self, version): def test_manage_version_not_found(self, version):
body = get_fake_manage_body( body = get_fake_manage_body(

View File

@ -63,12 +63,25 @@ class ShareAPITest(test.TestCase):
stubs.stub_share_get) stubs.stub_share_get)
self.mock_object(share_api.API, 'update', stubs.stub_share_update) self.mock_object(share_api.API, 'update', stubs.stub_share_update)
self.mock_object(share_api.API, 'delete', stubs.stub_share_delete) self.mock_object(share_api.API, 'delete', stubs.stub_share_delete)
self.mock_object(share_api.API, 'soft_delete',
stubs.stub_share_soft_delete)
self.mock_object(share_api.API, 'restore', stubs.stub_share_restore)
self.mock_object(share_api.API, 'get_snapshot', self.mock_object(share_api.API, 'get_snapshot',
stubs.stub_snapshot_get) stubs.stub_snapshot_get)
self.mock_object(share_types, 'get_share_type', self.mock_object(share_types, 'get_share_type',
stubs.stub_share_type_get) stubs.stub_share_type_get)
self.maxDiff = None self.maxDiff = None
self.share = { self.share = {
"id": "1",
"size": 100,
"display_name": "Share Test Name",
"display_description": "Share Test Desc",
"share_proto": "fakeproto",
"availability_zone": "zone1:host1",
"is_public": False,
"task_state": None
}
self.share_in_recycle_bin = {
"id": "1", "id": "1",
"size": 100, "size": 100,
"display_name": "Share Test Name", "display_name": "Share Test Name",
@ -77,6 +90,20 @@ class ShareAPITest(test.TestCase):
"availability_zone": "zone1:host1", "availability_zone": "zone1:host1",
"is_public": False, "is_public": False,
"task_state": None, "task_state": None,
"is_soft_deleted": True,
"status": "available"
}
self.share_in_recycle_bin_is_deleting = {
"id": "1",
"size": 100,
"display_name": "Share Test Name",
"display_description": "Share Test Desc",
"share_proto": "fakeproto",
"availability_zone": "zone1:host1",
"is_public": False,
"task_state": None,
"is_soft_deleted": True,
"status": "deleting"
} }
self.create_mock = mock.Mock( self.create_mock = mock.Mock(
return_value=stubs.stub_share( return_value=stubs.stub_share(
@ -234,6 +261,23 @@ class ShareAPITest(test.TestCase):
mock_revert_to_snapshot.assert_called_once_with( mock_revert_to_snapshot.assert_called_once_with(
utils.IsAMatcher(context.RequestContext), share, snapshot) utils.IsAMatcher(context.RequestContext), share, snapshot)
def test__revert_share_has_been_soft_deleted(self):
snapshot = copy.deepcopy(self.snapshot)
body = {'revert': {'snapshot_id': '2'}}
req = fakes.HTTPRequest.blank('/v2/fake/shares/1/action',
use_admin_context=False,
version='2.27')
self.mock_object(
self.controller, '_validate_revert_parameters',
mock.Mock(return_value=body['revert']))
self.mock_object(share_api.API, 'get',
mock.Mock(return_value=self.share_in_recycle_bin))
self.mock_object(
share_api.API, 'get_snapshot', mock.Mock(return_value=snapshot))
self.assertRaises(
webob.exc.HTTPForbidden, self.controller._revert,
req, 1, body)
def test__revert_not_supported(self): def test__revert_not_supported(self):
share = copy.deepcopy(self.share) share = copy.deepcopy(self.share)
@ -1100,6 +1144,24 @@ class ShareAPITest(test.TestCase):
db.share_update.assert_called_once_with(utils.IsAMatcher( db.share_update.assert_called_once_with(utils.IsAMatcher(
context.RequestContext), share['id'], update) context.RequestContext), share['id'], update)
def test_reset_task_state_share_has_been_soft_deleted(self):
share = self.share_in_recycle_bin
req = fakes.HTTPRequest.blank(
'/v2/fake/shares/%s/action' % share['id'],
use_admin_context=True,
version='2.22')
req.method = 'POST'
req.headers['content-type'] = 'application/json'
req.api_version_request.experimental = True
update = {'task_state': constants.TASK_STATE_MIGRATION_ERROR}
body = {'reset_task_state': update}
self.mock_object(share_api.API, 'get',
mock.Mock(return_value=share))
self.assertRaises(webob.exc.HTTPForbidden,
self.controller.reset_task_state, req, share['id'],
body)
def test_migration_complete(self): def test_migration_complete(self):
share = db_utils.create_share() share = db_utils.create_share()
req = fakes.HTTPRequest.blank( req = fakes.HTTPRequest.blank(
@ -1524,6 +1586,60 @@ class ShareAPITest(test.TestCase):
self.assertEqual(expected, res_dict['share']['access_rules_status']) self.assertEqual(expected, res_dict['share']['access_rules_status'])
def test_share_soft_delete(self):
req = fakes.HTTPRequest.blank('/v2/fake/shares/1/action',
version='2.69')
body = {"soft_delete": None}
resp = self.controller.share_soft_delete(req, 1, body)
self.assertEqual(202, resp.status_int)
def test_share_soft_delete_has_been_soft_deleted_already(self):
req = fakes.HTTPRequest.blank('/v2/fake/shares/1/action',
version='2.69')
body = {"soft_delete": None}
self.mock_object(share_api.API, 'get',
mock.Mock(return_value=self.share_in_recycle_bin))
self.mock_object(share_api.API, 'soft_delete',
mock.Mock(
side_effect=exception.InvalidShare(reason='err')))
self.assertRaises(
webob.exc.HTTPForbidden, self.controller.share_soft_delete,
req, 1, body)
def test_share_soft_delete_has_replicas(self):
req = fakes.HTTPRequest.blank('/v2/fake/shares/1/action',
version='2.69')
body = {"soft_delete": None}
self.mock_object(share_api.API, 'get',
mock.Mock(return_value=self.share))
self.mock_object(share_api.API, 'soft_delete',
mock.Mock(side_effect=exception.Conflict(err='err')))
self.assertRaises(
webob.exc.HTTPConflict, self.controller.share_soft_delete,
req, 1, body)
def test_share_restore(self):
req = fakes.HTTPRequest.blank('/v2/fake/shares/1/action',
version='2.69')
body = {"restore": None}
self.mock_object(share_api.API, 'get',
mock.Mock(return_value=self.share_in_recycle_bin))
resp = self.controller.share_restore(req, 1, body)
self.assertEqual(202, resp.status_int)
def test_share_restore_with_deleting_status(self):
req = fakes.HTTPRequest.blank('/v2/fake/shares/1/action',
version='2.69')
body = {"restore": None}
self.mock_object(
share_api.API, 'get',
mock.Mock(return_value=self.share_in_recycle_bin_is_deleting))
self.assertRaises(
webob.exc.HTTPForbidden, self.controller.share_restore,
req, 1, body)
def test_share_delete(self): def test_share_delete(self):
req = fakes.HTTPRequest.blank('/v2/fake/shares/1') req = fakes.HTTPRequest.blank('/v2/fake/shares/1')
resp = self.controller.delete(req, 1) resp = self.controller.delete(req, 1)
@ -1614,7 +1730,9 @@ class ShareAPITest(test.TestCase):
{'use_admin_context': True, 'version': '2.36'}, {'use_admin_context': True, 'version': '2.36'},
{'use_admin_context': False, 'version': '2.36'}, {'use_admin_context': False, 'version': '2.36'},
{'use_admin_context': True, 'version': '2.42'}, {'use_admin_context': True, 'version': '2.42'},
{'use_admin_context': False, 'version': '2.42'}) {'use_admin_context': False, 'version': '2.42'},
{'use_admin_context': False, 'version': '2.69'},
{'use_admin_context': True, 'version': '2.69'})
@ddt.unpack @ddt.unpack
def test_share_list_summary_with_search_opts(self, use_admin_context, def test_share_list_summary_with_search_opts(self, use_admin_context,
version): version):
@ -1640,6 +1758,9 @@ class ShareAPITest(test.TestCase):
search_opts.update( search_opts.update(
{'display_name~': 'fake', {'display_name~': 'fake',
'display_description~': 'fake'}) 'display_description~': 'fake'})
if (api_version.APIVersionRequest(version) >=
api_version.APIVersionRequest('2.69')):
search_opts.update({'is_soft_deleted': True})
method = 'get_all' method = 'get_all'
shares = [ shares = [
{'id': 'id1', 'display_name': 'n1'}, {'id': 'id1', 'display_name': 'n1'},
@ -1658,7 +1779,7 @@ class ShareAPITest(test.TestCase):
# fake_key should be filtered for non-admin # fake_key should be filtered for non-admin
url = '/v2/fake/shares?fake_key=fake_value' url = '/v2/fake/shares?fake_key=fake_value'
for k, v in search_opts.items(): for k, v in search_opts.items():
url = url + '&' + k + '=' + v url = url + '&' + k + '=' + str(v)
req = fakes.HTTPRequest.blank(url, version=version, req = fakes.HTTPRequest.blank(url, version=version,
use_admin_context=use_admin_context) use_admin_context=use_admin_context)
@ -1691,6 +1812,10 @@ class ShareAPITest(test.TestCase):
search_opts_expected.update( search_opts_expected.update(
{'display_name~': search_opts['display_name~'], {'display_name~': search_opts['display_name~'],
'display_description~': search_opts['display_description~']}) 'display_description~': search_opts['display_description~']})
if (api_version.APIVersionRequest(version) >=
api_version.APIVersionRequest('2.69')):
search_opts_expected['is_soft_deleted'] = (
search_opts['is_soft_deleted'])
if use_admin_context: if use_admin_context:
search_opts_expected.update({'fake_key': 'fake_value'}) search_opts_expected.update({'fake_key': 'fake_value'})
@ -1778,7 +1903,9 @@ class ShareAPITest(test.TestCase):
{'use_admin_context': True, 'version': '2.35'}, {'use_admin_context': True, 'version': '2.35'},
{'use_admin_context': False, 'version': '2.35'}, {'use_admin_context': False, 'version': '2.35'},
{'use_admin_context': True, 'version': '2.42'}, {'use_admin_context': True, 'version': '2.42'},
{'use_admin_context': False, 'version': '2.42'}) {'use_admin_context': False, 'version': '2.42'},
{'use_admin_context': True, 'version': '2.69'},
{'use_admin_context': False, 'version': '2.69'})
@ddt.unpack @ddt.unpack
def test_share_list_detail_with_search_opts(self, use_admin_context, def test_share_list_detail_with_search_opts(self, use_admin_context,
version): version):
@ -1812,6 +1939,8 @@ class ShareAPITest(test.TestCase):
'share_type_id': 'fake_share_type_id', 'share_type_id': 'fake_share_type_id',
}, },
'has_replicas': False, 'has_replicas': False,
'is_soft_deleted': True,
'scheduled_to_be_deleted_at': 'fake_datatime',
}, },
{'id': 'id3', 'display_name': 'n3'}, {'id': 'id3', 'display_name': 'n3'},
] ]
@ -1823,12 +1952,15 @@ class ShareAPITest(test.TestCase):
search_opts.update({'with_count': 'true'}) search_opts.update({'with_count': 'true'})
method = 'get_all_with_count' method = 'get_all_with_count'
mock_action = {'side_effect': [(1, [shares[1]])]} mock_action = {'side_effect': [(1, [shares[1]])]}
if (api_version.APIVersionRequest(version) >=
api_version.APIVersionRequest('2.69')):
search_opts.update({'is_soft_deleted': True})
if use_admin_context: if use_admin_context:
search_opts['host'] = 'fake_host' search_opts['host'] = 'fake_host'
# fake_key should be filtered for non-admin # fake_key should be filtered for non-admin
url = '/v2/fake/shares/detail?fake_key=fake_value' url = '/v2/fake/shares/detail?fake_key=fake_value'
for k, v in search_opts.items(): for k, v in search_opts.items():
url = url + '&' + k + '=' + v url = url + '&' + k + '=' + str(v)
req = fakes.HTTPRequest.blank(url, version=version, req = fakes.HTTPRequest.blank(url, version=version,
use_admin_context=use_admin_context) use_admin_context=use_admin_context)
@ -1857,6 +1989,10 @@ class ShareAPITest(test.TestCase):
search_opts['export_location_id']) search_opts['export_location_id'])
search_opts_expected['export_location_path'] = ( search_opts_expected['export_location_path'] = (
search_opts['export_location_path']) search_opts['export_location_path'])
if (api_version.APIVersionRequest(version) >=
api_version.APIVersionRequest('2.69')):
search_opts_expected['is_soft_deleted'] = (
search_opts['is_soft_deleted'])
if use_admin_context: if use_admin_context:
search_opts_expected.update({'fake_key': 'fake_value'}) search_opts_expected.update({'fake_key': 'fake_value'})
@ -1889,6 +2025,11 @@ class ShareAPITest(test.TestCase):
if (api_version.APIVersionRequest(version) >= if (api_version.APIVersionRequest(version) >=
api_version.APIVersionRequest('2.42')): api_version.APIVersionRequest('2.42')):
self.assertEqual(1, result['count']) self.assertEqual(1, result['count'])
if (api_version.APIVersionRequest(version) >=
api_version.APIVersionRequest('2.69')):
self.assertEqual(
shares[1]['scheduled_to_be_deleted_at'],
result['shares'][0]['scheduled_to_be_deleted_at'])
def _list_detail_common_expected(self, admin=False): def _list_detail_common_expected(self, admin=False):
share_dict = { share_dict = {
@ -2530,6 +2671,7 @@ class ShareAdminActionsAPITest(test.TestCase):
req.headers['X-Openstack-Manila-Api-Version'] = version req.headers['X-Openstack-Manila-Api-Version'] = version
req.body = jsonutils.dumps(body).encode("utf-8") req.body = jsonutils.dumps(body).encode("utf-8")
req.environ['manila.context'] = ctxt req.environ['manila.context'] = ctxt
self.mock_object(share_api.API, 'get', mock.Mock(return_value=model))
resp = req.get_response(fakes.app()) resp = req.get_response(fakes.app())
@ -2565,7 +2707,7 @@ class ShareAdminActionsAPITest(test.TestCase):
@ddt.data('2.6', '2.7') @ddt.data('2.6', '2.7')
def test_share_reset_status_for_missing(self, version): def test_share_reset_status_for_missing(self, version):
fake_share = {'id': 'missing-share-id'} fake_share = {'id': 'missing-share-id', 'is_soft_deleted': False}
req = fakes.HTTPRequest.blank( req = fakes.HTTPRequest.blank(
'/v2/fake/shares/%s/action' % fake_share['id'], version=version) '/v2/fake/shares/%s/action' % fake_share['id'], version=version)

View File

@ -55,13 +55,15 @@ class ViewBuilderTestCase(test.TestCase):
'create_share_from_snapshot_support': True, 'create_share_from_snapshot_support': True,
'revert_to_snapshot_support': True, 'revert_to_snapshot_support': True,
'progress': '100%', 'progress': '100%',
'scheduled_to_be_deleted_at': 'fake_datetime',
} }
return stubs.stub_share('fake_id', **fake_share) return stubs.stub_share('fake_id', **fake_share)
def test__collection_name(self): def test__collection_name(self):
self.assertEqual('shares', self.builder._collection_name) self.assertEqual('shares', self.builder._collection_name)
@ddt.data('2.6', '2.9', '2.10', '2.11', '2.16', '2.24', '2.27', '2.54') @ddt.data('2.6', '2.9', '2.10', '2.11', '2.16',
'2.24', '2.27', '2.54', '2.69')
def test_detail(self, microversion): def test_detail(self, microversion):
req = fakes.HTTPRequest.blank('/shares', version=microversion) req = fakes.HTTPRequest.blank('/shares', version=microversion)
@ -91,6 +93,8 @@ class ViewBuilderTestCase(test.TestCase):
expected['revert_to_snapshot_support'] = True expected['revert_to_snapshot_support'] = True
if self.is_microversion_ge(microversion, '2.54'): if self.is_microversion_ge(microversion, '2.54'):
expected['progress'] = '100%' expected['progress'] = '100%'
if self.is_microversion_ge(microversion, '2.69'):
expected['scheduled_to_be_deleted_at'] = 'fake_datetime'
self.assertSubDictMatch(expected, result['share']) self.assertSubDictMatch(expected, result['share'])

View File

@ -3044,3 +3044,43 @@ class AddUpdateSecurityServiceControlFields(BaseMigrationChecks):
self.test_case.assertRaises( self.test_case.assertRaises(
sa_exc.NoSuchTableError, sa_exc.NoSuchTableError,
utils.load_table, 'async_operation_data', engine) utils.load_table, 'async_operation_data', engine)
@map_to_migration('1946cb97bb8d')
class ShareIsSoftDeleted(BaseMigrationChecks):
def setup_upgrade_data(self, engine):
# Setup shares
share_fixture = [{'id': 'foo_share_id1'}, {'id': 'bar_share_id1'}]
share_table = utils.load_table('shares', engine)
for fixture in share_fixture:
engine.execute(share_table.insert(fixture))
# Setup share instances
si_fixture = [
{'id': 'foo_share_instance_id_oof1',
'share_id': share_fixture[0]['id'],
'cast_rules_to_readonly': False},
{'id': 'bar_share_instance_id_rab1',
'share_id': share_fixture[1]['id'],
'cast_rules_to_readonly': False},
]
si_table = utils.load_table('share_instances', engine)
for fixture in si_fixture:
engine.execute(si_table.insert(fixture))
def check_upgrade(self, engine, data):
s_table = utils.load_table('shares', engine)
for s in engine.execute(s_table.select()):
self.test_case.assertTrue(hasattr(s, 'is_soft_deleted'))
self.test_case.assertTrue(hasattr(s,
'scheduled_to_be_deleted_at'))
self.test_case.assertIn(s['is_soft_deleted'], (0, False))
self.test_case.assertIsNone(s['scheduled_to_be_deleted_at'])
def check_downgrade(self, engine):
s_table = utils.load_table('shares', engine)
for s in engine.execute(s_table.select()):
self.test_case.assertFalse(hasattr(s, 'is_soft_deleted'))
self.test_case.assertFalse(hasattr(s,
'scheduled_to_be_deleted_at'))

View File

@ -377,6 +377,34 @@ class ShareDatabaseAPITestCase(test.TestCase):
self.assertEqual(1, len(actual_result)) self.assertEqual(1, len(actual_result))
self.assertEqual(share['id'], actual_result[0].id) self.assertEqual(share['id'], actual_result[0].id)
def test_share_in_recycle_bin_filter_all_by_share_server(self):
share_network = db_utils.create_share_network()
share_server = db_utils.create_share_server(
share_network_id=share_network['id'])
share = db_utils.create_share(share_server_id=share_server['id'],
share_network_id=share_network['id'],
is_soft_deleted=True)
actual_result = db_api.get_shares_in_recycle_bin_by_share_server(
self.ctxt, share_server['id'])
self.assertEqual(1, len(actual_result))
self.assertEqual(share['id'], actual_result[0].id)
def test_share_in_recycle_bin_filter_all_by_share_network(self):
share_network = db_utils.create_share_network()
share_server = db_utils.create_share_server(
share_network_id=share_network['id'])
share = db_utils.create_share(share_server_id=share_server['id'],
share_network_id=share_network['id'],
is_soft_deleted=True)
actual_result = db_api.get_shares_in_recycle_bin_by_network(
self.ctxt, share_network['id'])
self.assertEqual(1, len(actual_result))
self.assertEqual(share['id'], actual_result[0].id)
def test_share_filter_all_by_share_group(self): def test_share_filter_all_by_share_group(self):
group = db_utils.create_share_group() group = db_utils.create_share_group()
share = db_utils.create_share(share_group_id=group['id']) share = db_utils.create_share(share_group_id=group['id'])
@ -506,6 +534,18 @@ class ShareDatabaseAPITestCase(test.TestCase):
self.assertEqual('share-%s' % instance['id'], instance['name']) self.assertEqual('share-%s' % instance['id'], instance['name'])
def test_share_instance_get_all_by_is_soft_deleted(self):
db_utils.create_share()
db_utils.create_share(is_soft_deleted=True)
instances = db_api.share_instances_get_all(
self.ctxt, filters={'is_soft_deleted': True})
self.assertEqual(1, len(instances))
instance = instances[0]
self.assertEqual('share-%s' % instance['id'], instance['name'])
def test_share_instance_get_all_by_ids(self): def test_share_instance_get_all_by_ids(self):
fake_share = db_utils.create_share() fake_share = db_utils.create_share()
expected_share_instance = db_utils.create_share_instance( expected_share_instance = db_utils.create_share_instance(
@ -653,6 +693,25 @@ class ShareDatabaseAPITestCase(test.TestCase):
self.assertEqual(shares[0]['id'], result[0]['id']) self.assertEqual(shares[0]['id'], result[0]['id'])
self.assertEqual(1, len(result)) self.assertEqual(1, len(result))
def test_share_get_all_expired(self):
now_time = timeutils.utcnow()
time_delta = datetime.timedelta(seconds=3600)
time1 = now_time + time_delta
time2 = now_time - time_delta
share1 = db_utils.create_share(status=constants.STATUS_AVAILABLE,
is_soft_deleted=False,
scheduled_to_be_deleted_at=None)
share2 = db_utils.create_share(status=constants.STATUS_AVAILABLE,
is_soft_deleted=True,
scheduled_to_be_deleted_at=time1)
share3 = db_utils.create_share(status=constants.STATUS_AVAILABLE,
is_soft_deleted=True,
scheduled_to_be_deleted_at=time2)
shares = [share1, share2, share3]
result = db_api.get_all_expired_shares(self.ctxt)
self.assertEqual(1, len(result))
self.assertEqual(shares[2]['id'], result[0]['id'])
@ddt.data( @ddt.data(
({'status': constants.STATUS_AVAILABLE}, 'status', ({'status': constants.STATUS_AVAILABLE}, 'status',
[constants.STATUS_AVAILABLE, constants.STATUS_ERROR]), [constants.STATUS_AVAILABLE, constants.STATUS_ERROR]),
@ -669,7 +728,9 @@ class ShareDatabaseAPITestCase(test.TestCase):
({'display_name': 'fake_share_name'}, 'display_name', ({'display_name': 'fake_share_name'}, 'display_name',
['fake_share_name', 'share_name']), ['fake_share_name', 'share_name']),
({'display_description': 'fake description'}, 'display_description', ({'display_description': 'fake description'}, 'display_description',
['fake description', 'description']) ['fake description', 'description']),
({'is_soft_deleted': True}, 'is_soft_deleted',
[True, False])
) )
@ddt.unpack @ddt.unpack
def test_share_get_all_with_filters(self, filters, key, share_values): def test_share_get_all_with_filters(self, filters, key, share_values):
@ -1047,6 +1108,20 @@ class ShareDatabaseAPITestCase(test.TestCase):
db_api.share_instance_access_get( db_api.share_instance_access_get(
self.ctxt, rule_id, instance['id'])) self.ctxt, rule_id, instance['id']))
def test_share_soft_delete(self):
share = db_utils.create_share()
db_api.share_soft_delete(self.ctxt, share['id'])
share = db_api.share_get(self.ctxt, share['id'])
self.assertEqual(share['is_soft_deleted'], True)
def test_share_restore(self):
share = db_utils.create_share(is_soft_deleted=True)
db_api.share_restore(self.ctxt, share['id'])
share = db_api.share_get(self.ctxt, share['id'])
self.assertEqual(share['is_soft_deleted'], False)
@ddt.ddt @ddt.ddt
class ShareGroupDatabaseAPITestCase(test.TestCase): class ShareGroupDatabaseAPITestCase(test.TestCase):
@ -4293,6 +4368,10 @@ class ShareResourcesAPITestCase(test.TestCase):
else: else:
new_host = 'new-controller-X' new_host = 'new-controller-X'
resources = [ # noqa resources = [ # noqa
# share
db_utils.create_share_without_instance(
id=share_id,
status=constants.STATUS_AVAILABLE),
# share instances # share instances
db_utils.create_share_instance( db_utils.create_share_instance(
share_id=share_id, share_id=share_id,
@ -4382,6 +4461,10 @@ class ShareResourcesAPITestCase(test.TestCase):
+ expected_updates['groups'] + expected_updates['groups']
+ expected_updates['servers']) + expected_updates['servers'])
resources = [ # noqa resources = [ # noqa
# share
db_utils.create_share_without_instance(
id=share_id,
status=constants.STATUS_AVAILABLE),
# share instances # share instances
db_utils.create_share_instance( db_utils.create_share_instance(
share_id=share_id, share_id=share_id,

View File

@ -91,7 +91,8 @@ def create_share(**kwargs):
'metadata': {'fake_key': 'fake_value'}, 'metadata': {'fake_key': 'fake_value'},
'availability_zone': 'fake_availability_zone', 'availability_zone': 'fake_availability_zone',
'status': constants.STATUS_CREATING, 'status': constants.STATUS_CREATING,
'host': 'fake_host' 'host': 'fake_host',
'is_soft_deleted': False
} }
return _create_db_row(db.share_create, share, kwargs) return _create_db_row(db.share_create, share, kwargs)
@ -108,7 +109,8 @@ def create_share_without_instance(**kwargs):
'metadata': {}, 'metadata': {},
'availability_zone': 'fake_availability_zone', 'availability_zone': 'fake_availability_zone',
'status': constants.STATUS_CREATING, 'status': constants.STATUS_CREATING,
'host': 'fake_host' 'host': 'fake_host',
'is_soft_deleted': False
} }
share.update(copy.deepcopy(kwargs)) share.update(copy.deepcopy(kwargs))
return db.share_create(context.get_admin_context(), share, False) return db.share_create(context.get_admin_context(), share, False)

View File

@ -4663,6 +4663,9 @@ class ShareAPITestCase(test.TestCase):
mock_shares_get_all = self.mock_object( mock_shares_get_all = self.mock_object(
db_api, 'share_get_all_by_share_server', db_api, 'share_get_all_by_share_server',
mock.Mock(return_value=[fake_share])) mock.Mock(return_value=[fake_share]))
mock_shares_in_recycle_bin_get_all = self.mock_object(
db_api, 'get_shares_in_recycle_bin_by_share_server',
mock.Mock(return_value=[]))
mock_get_type = self.mock_object( mock_get_type = self.mock_object(
share_types, 'get_share_type', mock.Mock(return_value=share_type)) share_types, 'get_share_type', mock.Mock(return_value=share_type))
mock_validate_service = self.mock_object( mock_validate_service = self.mock_object(
@ -4691,6 +4694,8 @@ class ShareAPITestCase(test.TestCase):
mock_shares_get_all.assert_has_calls([ mock_shares_get_all.assert_has_calls([
mock.call(self.context, fake_share_server['id']), mock.call(self.context, fake_share_server['id']),
mock.call(self.context, fake_share_server['id'])]) mock.call(self.context, fake_share_server['id'])])
mock_shares_in_recycle_bin_get_all.assert_has_calls([
mock.call(self.context, fake_share_server['id'])])
mock_get_type.assert_called_once_with(self.context, share_type['id']) mock_get_type.assert_called_once_with(self.context, share_type['id'])
mock_validate_service.assert_called_once_with(self.context, fake_host) mock_validate_service.assert_called_once_with(self.context, fake_host)
mock_service_get.assert_called_once_with( mock_service_get.assert_called_once_with(
@ -6279,6 +6284,79 @@ class ShareAPITestCase(test.TestCase):
new_sec_service_id, new_sec_service_id,
current_security_service_id=curr_sec_service_id) current_security_service_id=curr_sec_service_id)
def test_soft_delete_share_already_soft_deleted(self):
share = fakes.fake_share(id='fake_id',
status=constants.STATUS_AVAILABLE,
is_soft_deleted=True)
self.assertRaises(exception.InvalidShare,
self.api.soft_delete, self.context, share)
def test_soft_delete_invalid_status(self):
invalid_status = 'fake'
share = fakes.fake_share(id='fake_id',
status=invalid_status,
is_soft_deleted=False)
self.assertRaises(exception.InvalidShare,
self.api.soft_delete, self.context, share)
def test_soft_delete_share_with_replicas(self):
share = fakes.fake_share(id='fake_id',
has_replicas=True,
status=constants.STATUS_AVAILABLE,
is_soft_deleted=False)
self.assertRaises(exception.Conflict,
self.api.soft_delete, self.context, share)
def test_soft_delete_share_with_snapshot(self):
share = fakes.fake_share(id='fake_id',
status=constants.STATUS_AVAILABLE,
has_replicas=False,
is_soft_deleted=False)
snapshot = fakes.fake_snapshot(create_instance=True, as_primitive=True)
mock_db_snapshot_call = self.mock_object(
db_api, 'share_snapshot_get_all_for_share', mock.Mock(
return_value=[snapshot]))
self.assertRaises(exception.InvalidShare,
self.api.soft_delete, self.context, share)
mock_db_snapshot_call.assert_called_once_with(
self.context, share['id'])
@mock.patch.object(db_api, 'count_share_group_snapshot_members_in_share',
mock.Mock(return_value=2))
def test_soft_delete_share_with_group_snapshot_members(self):
share = fakes.fake_share(id='fake_id',
status=constants.STATUS_AVAILABLE,
has_replicas=False,
is_soft_deleted=False)
self.assertRaises(exception.InvalidShare,
self.api.soft_delete, self.context, share)
def test_soft_delete_share(self):
share = fakes.fake_share(id='fake_id',
status=constants.STATUS_AVAILABLE,
has_replicas=False,
is_soft_deleted=False)
self.mock_object(db_api, 'share_snapshot_get_all_for_share',
mock.Mock(return_value=[]))
self.mock_object(db_api, 'count_share_group_snapshot_members_in_share',
mock.Mock(return_value=0))
self.mock_object(db_api, 'share_soft_delete')
self.mock_object(self.api, '_check_is_share_busy')
self.api.soft_delete(self.context, share)
self.api._check_is_share_busy.assert_called_once_with(share)
def test_restore_share(self):
share = fakes.fake_share(id='fake_id',
status=constants.STATUS_AVAILABLE,
is_soft_deleted=True)
self.mock_object(db_api, 'share_restore')
self.api.restore(self.context, share)
class OtherTenantsShareActionsTestCase(test.TestCase): class OtherTenantsShareActionsTestCase(test.TestCase):
def setUp(self): def setUp(self):

View File

@ -208,6 +208,7 @@ class ShareManagerTestCase(test.TestCase):
"unmanage_share", "unmanage_share",
"delete_share_instance", "delete_share_instance",
"delete_free_share_servers", "delete_free_share_servers",
"delete_expired_share",
"create_snapshot", "create_snapshot",
"delete_snapshot", "delete_snapshot",
"update_access", "update_access",
@ -3938,6 +3939,18 @@ class ShareManagerTestCase(test.TestCase):
'server1') 'server1')
timeutils.utcnow.assert_called_once_with() timeutils.utcnow.assert_called_once_with()
@mock.patch.object(db, 'get_all_expired_shares',
mock.Mock(return_value=[{"id": "share1"}, ]))
@mock.patch.object(api.API, 'delete',
mock.Mock())
def test_delete_expired_share(self):
self.share_manager.delete_expired_share(self.context)
db.get_all_expired_shares.assert_called_once_with(
self.context)
share1 = {"id": "share1"}
api.API.delete.assert_called_once_with(
self.context, share1, force=True)
@mock.patch('manila.tests.fake_notifier.FakeNotifier._notify') @mock.patch('manila.tests.fake_notifier.FakeNotifier._notify')
def test_extend_share_invalid(self, mock_notify): def test_extend_share_invalid(self, mock_notify):
share = db_utils.create_share() share = db_utils.create_share()

View File

@ -0,0 +1,17 @@
---
features:
- |
Manila now supports a "recycle bin" for shares. End users can soft-delete
their shares and have the ability to restore them for a specified interval.
This interval defaults to 7 days and is configurable via
"soft_deleted_share_retention_time". After this time has elapsed,
soft-deleted shares are automatically cleaned up.
upgrade:
- |
The share entity now contains two new fields: ``is_soft_deleted`` and
``scheduled_to_be_deleted_at``. The ``is_soft_deleted`` will be used to
identify shares in the recycle bin.. The ``scheduled_to_be_deleted_at``
field to show when the share will be deleted automatically. A new parameter
called ``is_soft_deleted`` was added to the share list API, and users will
be able to query shares and filter out the ones that are currently in the
recycle bin.