From f641577d8a872ff381fe1c465e67b198c052b0be Mon Sep 17 00:00:00 2001 From: Goutham Pacha Ravi Date: Tue, 27 Jun 2023 13:09:00 -0700 Subject: [PATCH] Resource Locks: Support for share deletion lock Add CRUD APIs for resource locks with support for preventing deletion of shares (applies to soft-deletions and unmanage operations as well). Change-Id: I146bc09e4e8a39797e22458ff6860346e11e592e Implements: bp/allow-locking-shares-against-deletion Signed-off-by: Goutham Pacha Ravi --- api-ref/source/index.rst | 1 + api-ref/source/parameters.yaml | 212 +++++++++- api-ref/source/resource-locks.inc | 325 +++++++++++++++ .../samples/resource-lock-create-request.json | 8 + .../resource-lock-create-response.json | 24 ++ .../resource-lock-get-all-response.json | 48 +++ .../samples/resource-lock-get-response.json | 24 ++ .../samples/resource-lock-update-request.json | 5 + .../resource-lock-update-response.json | 24 ++ doc/source/user/create-and-manage-shares.rst | 72 ++++ manila/api/openstack/api_version_request.py | 4 +- .../openstack/rest_api_version_history.rst | 5 + manila/api/v2/resource_locks.py | 187 +++++++++ manila/api/v2/router.py | 5 + manila/api/views/resource_locks.py | 72 ++++ manila/common/constants.py | 10 + manila/context.py | 2 + manila/db/api.py | 27 ++ .../cb20f743ca7b_add_resource_locks.py | 65 +++ manila/db/sqlalchemy/api.py | 105 +++++ manila/db/sqlalchemy/models.py | 25 ++ manila/exception.py | 4 + manila/lock/__init__.py | 0 manila/lock/api.py | 172 ++++++++ manila/policies/__init__.py | 2 + manila/policies/base.py | 35 ++ manila/policies/resource_lock.py | 154 +++++++ manila/share/api.py | 53 +++ manila/tests/api/v2/stubs.py | 20 + manila/tests/api/v2/test_resource_locks.py | 373 +++++++++++++++++ .../alembic/migrations_data_checks.py | 31 ++ manila/tests/db/sqlalchemy/test_api.py | 174 ++++++++ manila/tests/db_utils.py | 15 + manila/tests/lock/__init__.py | 0 manila/tests/lock/test_api.py | 388 ++++++++++++++++++ manila/tests/share/test_api.py | 62 +++ manila/tests/test_context.py | 7 + manila/tests/test_exception.py | 7 + ...res-against-deletion-5a715292e720a254.yaml | 9 + 39 files changed, 2752 insertions(+), 4 deletions(-) create mode 100644 api-ref/source/resource-locks.inc create mode 100644 api-ref/source/samples/resource-lock-create-request.json create mode 100644 api-ref/source/samples/resource-lock-create-response.json create mode 100644 api-ref/source/samples/resource-lock-get-all-response.json create mode 100644 api-ref/source/samples/resource-lock-get-response.json create mode 100644 api-ref/source/samples/resource-lock-update-request.json create mode 100644 api-ref/source/samples/resource-lock-update-response.json create mode 100644 manila/api/v2/resource_locks.py create mode 100644 manila/api/views/resource_locks.py create mode 100644 manila/db/migrations/alembic/versions/cb20f743ca7b_add_resource_locks.py create mode 100644 manila/lock/__init__.py create mode 100644 manila/lock/api.py create mode 100644 manila/policies/resource_lock.py create mode 100644 manila/tests/api/v2/test_resource_locks.py create mode 100644 manila/tests/lock/__init__.py create mode 100644 manila/tests/lock/test_api.py create mode 100644 releasenotes/notes/bp-allow-locking-shares-against-deletion-5a715292e720a254.yaml diff --git a/api-ref/source/index.rst b/api-ref/source/index.rst index 540ccaab53..100770fa9c 100644 --- a/api-ref/source/index.rst +++ b/api-ref/source/index.rst @@ -55,6 +55,7 @@ shared file system storage resources. .. include:: share-group-types.inc .. include:: share-group-snapshots.inc .. include:: share-transfers.inc +.. include:: resource-locks.inc ====================================== Shared File Systems API (EXPERIMENTAL) diff --git a/api-ref/source/parameters.yaml b/api-ref/source/parameters.yaml index b61a870a9a..c75f598d7d 100644 --- a/api-ref/source/parameters.yaml +++ b/api-ref/source/parameters.yaml @@ -1,5 +1,16 @@ # variables in header #{} +service_token_locks: + description: | + An auth-token specified via the header ``X-Service-Token``. With the + OpenStack Identity (Keystone) context, this token can be obtained by + a user that has the ``service`` role. The presence of this header is + used by resource lock API methods to set or match the lock user's context. + A resource lock created by a service user cannot be manipulated by + non-service users. + in: header + required: false + type: string # variables in path access_id_path: @@ -83,6 +94,12 @@ quota_class_name: in: path required: true type: string +resource_lock_id_path: + description: | + The UUID of the resource lock. + in: path + required: true + type: string security_service_id_path: description: | The UUID of the security service. @@ -433,8 +450,7 @@ nova_net_id_query: max_version: 2.26 offset: description: | - The offset to define start point of share or share group - listing. + The offset to define start point of resource listing. in: query required: false type: integer @@ -463,6 +479,69 @@ resource_id: in: query required: false type: string +resource_lock_all_projects_query: + description: | + Set this parameter to True to get resource locks across all project + namespaces. + in: query + required: false + type: string +resource_lock_id_query: + description: | + The ID of the resource lock to filter resource locks by. + in: query + required: false + type: string +resource_lock_lock_context_query: + description: | + The lock creator's context to filter locks by. + in: query + required: false + type: string +resource_lock_lock_reason_inexact_query: + description: | + The lock reason pattern that can be used to filter resource locks. + in: query + required: false + type: string +resource_lock_lock_reason_query: + description: | + The lock reason that can be used to filter resource locks. + in: query + required: false + type: string +resource_lock_project_id_query: + description: | + The ID of a project to filter resource locks by. + in: query + required: false + type: string +resource_lock_resource_action_query: + description: | + The ``action`` prevented by the filtered resource locks. + in: query + required: false + type: string +resource_lock_resource_id_query: + description: | + The ID of the resource that the locks pertain to to filter resource + locks by. + in: query + required: false + type: string +resource_lock_resource_type_query: + description: | + The type of the resource that the locks pertain to to filter resource + locks by. + in: query + required: false + type: string +resource_lock_user_id_query: + description: | + The ID of a user to filter resource locks by. + in: query + required: false + type: string resource_type: description: | The type of the resource for which the message was created. @@ -618,6 +697,15 @@ sort_key_messages: in: query required: false type: string +sort_key_resource_locks: + description: | + The key to sort a list of resource locks. A valid value + is ``id``, ``resource_id``, ``resource_type``, ``resource_action``, + ``user_id``, ``project_id``, ``created_at``, ``updated_act``, + ``lock_context``. + in: query + required: false + type: string sort_key_transfer: description: | The key to sort a list of transfers. A valid value @@ -654,13 +742,22 @@ user_id_query: with_count_query: description: | Whether to show ``count`` in share list API response or not, default is ``False``. + This query parameter is useful with pagination. in: query required: false type: boolean min_version: 2.42 +with_count_query_without_min_version: + description: | + Whether to show ``count`` in API response or not, default is ``False``. + This query parameter is useful with pagination. + in: query + required: false + type: boolean with_count_snapshot_query: description: | Whether to show ``count`` in share snapshot list API response or not, default is ``False``. + This query parameter is useful with pagination. in: query required: false type: boolean @@ -1023,6 +1120,14 @@ count: required: false type: integer min_version: 2.42 +count_without_min_version: + description: | + The total count of requested resource before pagination is applied. This + parameter is only present in the API response if "with_count=True" is + supplied in the query. + in: body + required: false + type: integer create_share_from_snapshot_support: description: | Boolean extra spec used for filtering of back ends by @@ -2415,6 +2520,109 @@ resource_id_body: in: body required: true type: string +resource_lock_id: + description: | + The UUID identifying the specific resource lock. + in: body + required: true + type: string +resource_lock_lock_context: + description: | + The lock creator's context. Resource locks can be created by users with + different roles. If a user with ``admin`` role creates the lock, the value + of this field is ``admin``. If a user with ``service`` role creates the + lock, the value of this field is ``service``. For all other contexts, the + value of this field is ``user``. This field also determines the user's + role that is required to unlock or manipulate a lock by virtue of the + service's default RBAC. + in: body + required: true + type: string +resource_lock_lock_reason: + description: | + A blob of text representing the reason for the specific resource lock. + in: body + required: true + type: string +resource_lock_lock_reason_optional: + description: | + A blob of text representing the reason for the specific resource lock. + in: body + required: false + type: string +resource_lock_object: + description: | + A resource lock object when making resource lock requests. All other + parameters are included in this object. + in: body + required: true + type: object +resource_lock_project_id: + description: | + The ID of the project that the resource lock was created for. + in: body + required: true + type: string +resource_lock_resource_action: + description: | + The action pertaining to a resource that the resource lock prevents. For + example, if a resource lock prevents deletion of a share, the value of + ``resource_action`` is ``delete``. Resource locks are not supported for + all API actions. Currently support only exists for ``delete``, and for + specific resources. + in: body + required: true + type: string +resource_lock_resource_action_create_optional: + description: | + The action pertaining to a resource that the resource lock prevents. For + example, if a resource lock prevents deletion of a share, the value of + ``resource_action`` is ``delete``. Resource locks are not supported for + all API actions. Currently support only exists for ``delete``, and for + specific resources. If not provided, the value of this parameter + defaults to ``delete``. + in: body + required: false + type: string +resource_lock_resource_action_optional: + description: | + The action pertaining to a resource that the resource lock prevents. For + example, if a resource lock prevents deletion of a share, the value of + ``resource_action`` is ``delete``. Resource locks are not supported for + all API actions. Currently support only exists for ``delete``, and for + specific resources. + in: body + required: false + type: string +resource_lock_resource_id: + description: | + The UUID of the resource that the lock pertains to. For example, this + can be the ID of the share that is locked from deletion. + in: body + required: true + type: string +resource_lock_resource_type: + description: | + The type of resource that the ID in ``resource_id`` denotes. For + example, ``share`` is the resource type that is specified when the + resource lock pertains to a share being locked from deletion. Resource + locks are not supported for all resources. Currently support only + exists for ``share``. + in: body + required: true + type: string +resource_lock_user_id: + description: | + The ID of the user the resource lock was created for. + in: body + required: true + type: string +resource_locks_object: + description: | + A resource locks object containing a collection or list of resource locks. + in: body + required: true + type: object resource_type_body: description: | The type of the resource for which the message was created. diff --git a/api-ref/source/resource-locks.inc b/api-ref/source/resource-locks.inc new file mode 100644 index 0000000000..e5eab57598 --- /dev/null +++ b/api-ref/source/resource-locks.inc @@ -0,0 +1,325 @@ +.. -*- rst -*- + +Resource Locks (since API v2.81) +================================ + +Create, list, update and delete locks on user actions on resources. + + +Create a resource lock +~~~~~~~~~~~~~~~~~~~~~~~ + +.. rest_method:: POST /v2/resource-locks + +.. versionadded:: 2.81 + +Lock a specific action on a given resource. + +Not all resources are supported, and not actions on supported resources can +be prevented with this mechanism. A lock can only be removed or manipulated +by the user that created it, or by a more privileged user. The cloud +administrator can use a ``policy.yaml`` file to tweak permissions on who +can manipulate and delete locks created by other users. + +Response codes +-------------- + +.. rest_status_code:: success status.yaml + + - 200 + +.. rest_status_code:: error status.yaml + + - 400 + - 401 + - 403 + - 404 + - 409 + + +Request +------- + +.. rest_parameters:: parameters.yaml + + - service_token: service_token_locks + - resource_lock: resource_lock_object + - resource_id: resource_lock_resource_id + - resource_type: resource_lock_resource_type + - resource_action: resource_lock_resource_action_create_optional + - lock_reason: resource_lock_lock_reason_optional + +Request Example +--------------- + +.. literalinclude:: ./samples/resource-lock-create-request.json + :language: javascript + + +Response Parameters +------------------- + +.. rest_parameters:: parameters.yaml + + - resource_lock: resource_lock_object + - id: resource_lock_id + - user_id: resource_lock_user_id + - project_id: resource_lock_project_id + - lock_context: resource_lock_lock_context + - resource_type: resource_lock_resource_type + - resource_id: resource_lock_resource_id + - resource_action: resource_lock_resource_action + - lock_reason: resource_lock_lock_reason + - created_at: created_at + - updated_at: updated_at + - links: links + +Response Example +---------------- + +.. literalinclude:: ./samples/resource-lock-create-response.json + :language: javascript + + +List resource locks +~~~~~~~~~~~~~~~~~~~ + +.. rest_method:: GET /v2/resource-locks + +.. versionadded:: 2.81 + +Retrieve resource locks with filters + +Response codes +-------------- + +.. rest_status_code:: success status.yaml + + - 200 + +.. rest_status_code:: error status.yaml + + - 401 + - 403 + - 404 + + +Request +------- + +.. rest_parameters:: parameters.yaml + + - service_token: service_token_locks + - id: resource_lock_id_query + - resource_id: resource_lock_resource_id_query + - resource_action: resource_lock_resource_action_query + - resource_type: resource_lock_resource_type_query + - user_id: resource_lock_user_id_query + - project_id: resource_lock_project_id_query + - all_projects: resource_lock_all_projects_query + - lock_context: resource_lock_lock_context_query + - created_since: created_since_query + - created_before: created_before_query + - lock_reason: resource_lock_lock_reason_query + - lock_reason~: resource_lock_lock_reason_inexact_query + - sort_key: sort_key_resource_locks + - sort_dir: sort_dir + - offset: offset + - with_count: with_count_query_without_min_version + + +Response Parameters +------------------- + +.. rest_parameters:: parameters.yaml + + - resource_locks: resource_locks_object + - id: resource_lock_id + - user_id: resource_lock_user_id + - project_id: resource_lock_project_id + - lock_context: resource_lock_lock_context + - resource_type: resource_lock_resource_type + - resource_id: resource_lock_resource_id + - resource_action: resource_lock_resource_action + - lock_reason: resource_lock_lock_reason + - created_at: created_at + - updated_at: updated_at + - links: links + - count: count_without_min_version + +Response Example +---------------- + +.. literalinclude:: ./samples/resource-lock-get-all-response.json + :language: javascript + + +Get a resource lock +~~~~~~~~~~~~~~~~~~~ + +.. rest_method:: GET /v2/resource-locks/{resource-lock-id} + +.. versionadded:: 2.81 + +Retrieve a specific resource lock + +By default, resource locks can be viewed by all users within a project that +owns the locks. The cloud administrator can use a ``policy.yaml`` file to tweak +this behavior. + +Response codes +-------------- + +.. rest_status_code:: success status.yaml + + - 200 + +.. rest_status_code:: error status.yaml + + - 401 + - 403 + - 404 + + +Request +------- + +.. rest_parameters:: parameters.yaml + + - service_token: service_token_locks + - resource_id: resource_lock_id_path + + +Response Parameters +------------------- + +.. rest_parameters:: parameters.yaml + + - resource_lock: resource_lock_object + - id: resource_lock_id + - user_id: resource_lock_user_id + - project_id: resource_lock_project_id + - lock_context: resource_lock_lock_context + - resource_type: resource_lock_resource_type + - resource_id: resource_lock_resource_id + - resource_action: resource_lock_resource_action + - lock_reason: resource_lock_lock_reason + - created_at: created_at + - updated_at: updated_at + - links: links + +Response Example +---------------- + +.. literalinclude:: ./samples/resource-lock-get-response.json + :language: javascript + + +Update a resource lock +~~~~~~~~~~~~~~~~~~~~~~ + +.. rest_method:: PUT /v2/resource-locks/{resource-lock-id} + +.. versionadded:: 2.81 + +Update a specific resource lock + +By default, resource locks can be updated by the user that created the lock +unless the ``lock_context`` is set to ``admin`` or ``service``. A user with +``service`` role is required to manipulate locks that have a ``lock_context`` +set to ``service``. Users with ``admin`` role can manipulate all locks. +Administrators can use ``policy.yaml`` to tweak this behavior. + +Response codes +-------------- + +.. rest_status_code:: success status.yaml + + - 200 + +.. rest_status_code:: error status.yaml + + - 401 + - 403 + - 404 + + +Request +------- + +.. rest_parameters:: parameters.yaml + + - service_token: service_token_locks + - resource_id: resource_lock_id_path + - resource_lock: resource_lock_object + - resource_action: resource_lock_resource_action_optional + - lock_reason: resource_lock_lock_reason_optional + +Request Example +---------------- + +.. literalinclude:: ./samples/resource-lock-update-request.json + :language: javascript + +Response Parameters +------------------- + +.. rest_parameters:: parameters.yaml + + - resource_lock: resource_lock_object + - id: resource_lock_id + - user_id: resource_lock_user_id + - project_id: resource_lock_project_id + - lock_context: resource_lock_lock_context + - resource_type: resource_lock_resource_type + - resource_id: resource_lock_resource_id + - resource_action: resource_lock_resource_action + - lock_reason: resource_lock_lock_reason + - created_at: created_at + - updated_at: updated_at + - links: links + +Response Example +---------------- + +.. literalinclude:: ./samples/resource-lock-update-response.json + :language: javascript + + +Delete a resource lock +~~~~~~~~~~~~~~~~~~~~~~ + +.. rest_method:: DELETE /v2/resource-locks/{resource-lock-id} + +.. versionadded:: 2.81 + +Delete a specific resource lock + +By default, resource locks can be deleted by the user that created the lock +unless the ``lock_context`` is set to ``admin`` or ``service``. A user with +``service`` role is required to delete locks that have a ``lock_context`` +set to ``service``. Users with ``admin`` role can delete any lock. +Administrators can use ``policy.yaml`` to tweak this behavior. + +This request provides no response body. + +Response codes +-------------- + +.. rest_status_code:: success status.yaml + + - 204 + +.. rest_status_code:: error status.yaml + + - 401 + - 403 + - 404 + +Request +------- + +.. rest_parameters:: parameters.yaml + + - service_token: service_token_locks + - resource_id: resource_lock_id_path diff --git a/api-ref/source/samples/resource-lock-create-request.json b/api-ref/source/samples/resource-lock-create-request.json new file mode 100644 index 0000000000..f1515c9123 --- /dev/null +++ b/api-ref/source/samples/resource-lock-create-request.json @@ -0,0 +1,8 @@ +{ + "resource_lock": { + "resource_id": "5a313549-d346-44b6-9650-738ce08a9fee", + "resource_type": "share", + "resource_action": "delete", + "lock_reason": "Locked for deletion until year end audit." + } +} diff --git a/api-ref/source/samples/resource-lock-create-response.json b/api-ref/source/samples/resource-lock-create-response.json new file mode 100644 index 0000000000..134ae51991 --- /dev/null +++ b/api-ref/source/samples/resource-lock-create-response.json @@ -0,0 +1,24 @@ +{ + "resource_lock": { + "id": "713dc92d-bf5e-4b04-875b-2b2d284d8f94", + "user_id": "89de351d3b5744b9853ec4829aa0e714", + "project_id": "db2e72fef7864bbbbf210f22da7f1158", + "lock_context": "user", + "resource_type": "share", + "resource_id": "5a313549-d346-44b6-9650-738ce08a9fee", + "resource_action": "delete", + "lock_reason": "Locked for deletion until year end audit.", + "created_at": "2023-07-17T22:11:48.144302", + "updated_at": null, + "links": [ + { + "rel": "self", + "href": "http://203.0.113.30/share/v2/resource_locks/713dc92d-bf5e-4b04-875b-2b2d284d8f94" + }, + { + "rel": "bookmark", + "href": "http://203.0.113.30/share/resource_locks/713dc92d-bf5e-4b04-875b-2b2d284d8f94" + } + ] + } +} diff --git a/api-ref/source/samples/resource-lock-get-all-response.json b/api-ref/source/samples/resource-lock-get-all-response.json new file mode 100644 index 0000000000..a589c30e90 --- /dev/null +++ b/api-ref/source/samples/resource-lock-get-all-response.json @@ -0,0 +1,48 @@ +{ + "resource_locks": [ + { + "id": "118750ee-b62b-4cae-9a94-7da29a4f831f", + "user_id": "89de351d3b5744b9853ec4829aa0e714", + "project_id": "db2e72fef7864bbbbf210f22da7f1158", + "lock_context": "user", + "resource_type": "share", + "resource_id": "4c0b4d35-4ea8-4811-a1e2-a065c64225a8", + "resource_action": "delete", + "lock_reason": null, + "created_at": "2023-07-17T22:53:18.894553", + "updated_at": null, + "links": [ + { + "rel": "self", + "href": "http://203.0.113.30/share/v2/resource_locks/118750ee-b62b-4cae-9a94-7da29a4f831f" + }, + { + "rel": "bookmark", + "href": "http://203.0.113.30/share/resource_locks/118750ee-b62b-4cae-9a94-7da29a4f831f" + } + ] + }, + { + "id": "713dc92d-bf5e-4b04-875b-2b2d284d8f94", + "user_id": "89de351d3b5744b9853ec4829aa0e714", + "project_id": "db2e72fef7864bbbbf210f22da7f1158", + "lock_context": "user", + "resource_type": "share", + "resource_id": "5a313549-d346-44b6-9650-738ce08a9fee", + "resource_action": "delete", + "lock_reason": "Locked for deletion until year end audit.", + "created_at": "2023-07-17T22:11:48.144302", + "updated_at": null, + "links": [ + { + "rel": "self", + "href": "http://203.0.113.30/share/v2/resource_locks/713dc92d-bf5e-4b04-875b-2b2d284d8f94" + }, + { + "rel": "bookmark", + "href": "http://203.0.113.30/share/resource_locks/713dc92d-bf5e-4b04-875b-2b2d284d8f94" + } + ] + } + ] +} diff --git a/api-ref/source/samples/resource-lock-get-response.json b/api-ref/source/samples/resource-lock-get-response.json new file mode 100644 index 0000000000..134ae51991 --- /dev/null +++ b/api-ref/source/samples/resource-lock-get-response.json @@ -0,0 +1,24 @@ +{ + "resource_lock": { + "id": "713dc92d-bf5e-4b04-875b-2b2d284d8f94", + "user_id": "89de351d3b5744b9853ec4829aa0e714", + "project_id": "db2e72fef7864bbbbf210f22da7f1158", + "lock_context": "user", + "resource_type": "share", + "resource_id": "5a313549-d346-44b6-9650-738ce08a9fee", + "resource_action": "delete", + "lock_reason": "Locked for deletion until year end audit.", + "created_at": "2023-07-17T22:11:48.144302", + "updated_at": null, + "links": [ + { + "rel": "self", + "href": "http://203.0.113.30/share/v2/resource_locks/713dc92d-bf5e-4b04-875b-2b2d284d8f94" + }, + { + "rel": "bookmark", + "href": "http://203.0.113.30/share/resource_locks/713dc92d-bf5e-4b04-875b-2b2d284d8f94" + } + ] + } +} diff --git a/api-ref/source/samples/resource-lock-update-request.json b/api-ref/source/samples/resource-lock-update-request.json new file mode 100644 index 0000000000..6118d88744 --- /dev/null +++ b/api-ref/source/samples/resource-lock-update-request.json @@ -0,0 +1,5 @@ +{ + "resource_lock": { + "lock_reason": "This is a protected share" + } +} diff --git a/api-ref/source/samples/resource-lock-update-response.json b/api-ref/source/samples/resource-lock-update-response.json new file mode 100644 index 0000000000..8471ff7b6e --- /dev/null +++ b/api-ref/source/samples/resource-lock-update-response.json @@ -0,0 +1,24 @@ +{ + "resource_lock": { + "id": "118750ee-b62b-4cae-9a94-7da29a4f831f", + "user_id": "89de351d3b5744b9853ec4829aa0e714", + "project_id": "db2e72fef7864bbbbf210f22da7f1158", + "lock_context": "user", + "resource_type": "share", + "resource_id": "4c0b4d35-4ea8-4811-a1e2-a065c64225a8", + "resource_action": "delete", + "lock_reason": "This is a protected share", + "created_at": "2023-07-17T22:53:18.894553", + "updated_at": "2023-07-17T23:18:44.284565", + "links": [ + { + "rel": "self", + "href": "http://203.0.113.30/share/v2/resource_locks/118750ee-b62b-4cae-9a94-7da29a4f831f" + }, + { + "rel": "bookmark", + "href": "http://203.0.113.30/share/resource_locks/118750ee-b62b-4cae-9a94-7da29a4f831f" + } + ] + } +} diff --git a/doc/source/user/create-and-manage-shares.rst b/doc/source/user/create-and-manage-shares.rst index c12aabfaed..a8d70a655a 100644 --- a/doc/source/user/create-and-manage-shares.rst +++ b/doc/source/user/create-and-manage-shares.rst @@ -1031,4 +1031,76 @@ Share Transfer | expires_at | 2023-05-25T14:42:11.176049 | +------------------------+--------------------------------------+ +Resource locks +~~~~~~~~~~~~~~ +* Prevent a share from being deleted by creating a ``resource lock``: + + .. code-block:: console + + $ openstack share lock create myshare share + +-----------------+--------------------------------------+ + | Field | Value | + +-----------------+--------------------------------------+ + | created_at | 2023-07-18T05:11:56.626667 | + | id | dc7ec691-a505-47d0-b2ec-8eb7fb9270e4 | + | lock_context | user | + | lock_reason | None | + | project_id | db2e72fef7864bbbbf210f22da7f1158 | + | resource_action | delete | + | resource_id | 4c0b4d35-4ea8-4811-a1e2-a065c64225a8 | + | resource_type | share | + | updated_at | None | + | user_id | 89de351d3b5744b9853ec4829aa0e714 | + +-----------------+--------------------------------------+ + + .. note:: + + A ``delete`` (deletion) lock on a share would prevent deletion and other + actions on a share that are similar to deletion. Similar actions include + moving a share to the recycle bin for deferred deletion (``soft + deletion``) or removing a share from the Shared File Systems service + (``unmanage``). + + + +* Get details of a resource lock: + + .. code-block:: console + + $ openstack share lock list --resource myshare --resource-type share + +--------------------------------------+--------------------------------------+---------------+-----------------+ + | ID | Resource Id | Resource Type | Resource Action | + +--------------------------------------+--------------------------------------+---------------+-----------------+ + | dc7ec691-a505-47d0-b2ec-8eb7fb9270e4 | 4c0b4d35-4ea8-4811-a1e2-a065c64225a8 | share | delete | + +--------------------------------------+--------------------------------------+---------------+-----------------+ + + $ openstack share lock show dc7ec691-a505-47d0-b2ec-8eb7fb9270e4 + +-----------------+--------------------------------------+ + | Field | Value | + +-----------------+--------------------------------------+ + | ID | dc7ec691-a505-47d0-b2ec-8eb7fb9270e4 | + | Resource Id | 4c0b4d35-4ea8-4811-a1e2-a065c64225a8 | + | Resource Type | share | + | Resource Action | delete | + | Lock Context | user | + | User Id | 89de351d3b5744b9853ec4829aa0e714 | + | Project Id | db2e72fef7864bbbbf210f22da7f1158 | + | Created At | 2023-07-18T05:11:56.626667 | + | Updated At | None | + | Lock Reason | None | + +-----------------+--------------------------------------+ + +* Resource lock in action: + + .. code-block:: console + + $ openstack share delete myshare + Failed to delete share with name or ID 'myshare': Resource lock/s [dc7ec691-a505-47d0-b2ec-8eb7fb9270e4] prevent delete action. (HTTP 403) (Request-ID: req-331a8e31-e02a-40b2-accf-0f6dae1b6178) + 1 of 1 shares failed to delete. + +* Delete a resource lock: + + .. code-block:: console + + $ openstack share lock delete dc7ec691-a505-47d0-b2ec-8eb7fb9270e4 diff --git a/manila/api/openstack/api_version_request.py b/manila/api/openstack/api_version_request.py index c33f1afeb1..1071e9c0ef 100644 --- a/manila/api/openstack/api_version_request.py +++ b/manila/api/openstack/api_version_request.py @@ -198,14 +198,14 @@ REST_API_VERSION_HISTORY = """ * 2.79 - Added ``with_count`` in share snapshot list API to get total count info. * 2.80 - Added share backup APIs. - + * 2.81 - Added API methods, endpoint /resource-locks. """ # The minimum and maximum versions of the API supported # The default api version request is defined to be the # minimum version of the API supported. _MIN_API_VERSION = "2.0" -_MAX_API_VERSION = "2.80" +_MAX_API_VERSION = "2.81" DEFAULT_API_VERSION = _MIN_API_VERSION diff --git a/manila/api/openstack/rest_api_version_history.rst b/manila/api/openstack/rest_api_version_history.rst index 7e2d2effbb..b7ce842564 100644 --- a/manila/api/openstack/rest_api_version_history.rst +++ b/manila/api/openstack/rest_api_version_history.rst @@ -434,3 +434,8 @@ ____ 2.80 ---- Added share backup APIs. + +2.81 +---- + Introduce resource locks as a way users can restrict certain actions on + resources. Only share deletion can be prevented at this version. diff --git a/manila/api/v2/resource_locks.py b/manila/api/v2/resource_locks.py new file mode 100644 index 0000000000..ec68f3b8ae --- /dev/null +++ b/manila/api/v2/resource_locks.py @@ -0,0 +1,187 @@ +# 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. + +"""The resource_locks API controller module. + +This module handles the following requests: +GET /resource-locks +GET /resource-locks/{lock_id} +POST /resource-locks +PUT /resource-locks/{lock_id} +DELETE /resource-locks/{lock_id} +""" + +from http import client as http_client + +from oslo_utils import timeutils +from oslo_utils import uuidutils +import webob +from webob import exc + +from manila.api import common +from manila.api.openstack import wsgi +from manila.api.views import resource_locks as resource_locks_view +from manila.common import constants +from manila import exception +from manila.i18n import _ +from manila.lock import api as resource_locks +from manila import utils + +RESOURCE_LOCKS_MIN_API_VERSION = '2.81' + + +class ResourceLocksController(wsgi.Controller): + """The Resource Locks API controller for the OpenStack API.""" + + _view_builder_class = resource_locks_view.ViewBuilder + resource_name = 'resource_lock' + + def _check_body(self, body, for_update=False): + if 'resource_lock' not in body: + raise exc.HTTPBadRequest( + explanation="Malformed request body.") + lock_data = body['resource_lock'] + resource_id = lock_data.get('resource_id') or '' + resource_type = lock_data.get('resource_type') or '' + resource_action = (lock_data.get('resource_action') or + constants.RESOURCE_ACTION_DELETE) + lock_reason = lock_data.get('lock_reason') or '' + + if len(lock_reason) > 1023: + msg = _("'lock_reason' can contain a maximum of 1023 characters.") + raise exc.HTTPBadRequest(explanation=msg) + if resource_action not in constants.RESOURCE_LOCK_RESOURCE_ACTIONS: + msg = _("'resource_action' can only be one of %(actions)s" % + {'actions': constants.RESOURCE_LOCK_RESOURCE_ACTIONS}) + raise exc.HTTPBadRequest(explanation=msg) + + if for_update: + if set(lock_data.keys()) - {'resource_action', 'lock_reason'}: + msg = _("Only 'resource_action' and 'lock_reason' " + "can be updated.") + raise exc.HTTPBadRequest(explanation=msg) + else: + if not uuidutils.is_uuid_like(resource_id): + msg = _("Resource ID is required and must be in uuid format.") + raise exc.HTTPBadRequest(explanation=msg) + if resource_type not in constants.RESOURCE_LOCK_RESOURCE_TYPES: + msg = _("'resource_type' is required and must be one " + "of %s" % constants.RESOURCE_LOCK_RESOURCE_TYPES) + raise exc.HTTPBadRequest(explanation=msg) + + def __init__(self): + self.resource_locks_api = resource_locks.API() + super(ResourceLocksController, self).__init__() + + @wsgi.Controller.api_version(RESOURCE_LOCKS_MIN_API_VERSION) + @wsgi.Controller.authorize('get_all') + def index(self, req): + """Returns a list of locks, transformed through view builder.""" + context = req.environ['manila.context'] + filters = req.params.copy() + + params = common.get_pagination_params(req) + limit, offset = [params.pop('limit', None), params.pop('offset', None)] + sort_key, sort_dir = common.get_sort_params(filters) + for key in ('limit', 'offset'): + filters.pop(key, None) + + show_count = utils.get_bool_from_api_params( + 'with_count', {'with_count': filters.pop('with_count', False)}) + + for time_comparison_filter in ['created_since', 'created_before']: + if time_comparison_filter in filters: + time_str = filters.get(time_comparison_filter) + try: + parsed_time = timeutils.parse_isotime(time_str) + filters[time_comparison_filter] = parsed_time + except ValueError: + msg = _('Invalid value specified for the query ' + 'key: %s') % time_comparison_filter + raise exc.HTTPBadRequest(explanation=msg) + + locks, count = self.resource_locks_api.get_all(context, + search_opts=filters, + limit=limit, + offset=offset, + sort_key=sort_key, + sort_dir=sort_dir, + show_count=show_count) + + return self._view_builder.index(req, + locks, + count=count) + + @wsgi.Controller.api_version(RESOURCE_LOCKS_MIN_API_VERSION) + @wsgi.Controller.authorize('get') + def show(self, req, id): + """Return an existing resource lock by ID.""" + context = req.environ['manila.context'] + try: + resource_lock = self.resource_locks_api.get(context, id) + except exception.ResourceLockNotFound as error: + raise exc.HTTPNotFound(explanation=error.msg) + return self._view_builder.detail(req, resource_lock) + + @wsgi.Controller.api_version(RESOURCE_LOCKS_MIN_API_VERSION) + @wsgi.Controller.authorize + @wsgi.action("delete") + def delete(self, req, id): + """Delete an existing resource lock.""" + context = req.environ['manila.context'] + try: + self.resource_locks_api.delete(context, id) + except exception.ResourceLockNotFound as error: + raise exc.HTTPNotFound(explanation=error.msg) + return webob.Response(status_int=http_client.NO_CONTENT) + + @wsgi.Controller.api_version(RESOURCE_LOCKS_MIN_API_VERSION) + @wsgi.Controller.authorize + def create(self, req, body): + """Create a resource lock.""" + context = req.environ['manila.context'] + self._check_body(body) + lock_data = body['resource_lock'] + try: + resource_lock = self.resource_locks_api.create( + context, + resource_id=lock_data['resource_id'], + resource_type=lock_data['resource_type'], + resource_action=(lock_data.get('resource_action') or + constants.RESOURCE_ACTION_DELETE), + lock_reason=lock_data.get('lock_reason') + ) + except exception.NotFound: + raise exc.HTTPBadRequest( + explanation="No such resource found.") + except exception.InvalidInput as error: + raise exc.HTTPConflict(explanation=error.msg) + return self._view_builder.detail(req, resource_lock) + + @wsgi.Controller.api_version(RESOURCE_LOCKS_MIN_API_VERSION) + @wsgi.Controller.authorize + def update(self, req, id, body): + """Update an existing resource lock.""" + context = req.environ['manila.context'] + self._check_body(body, for_update=True) + lock_data = body['resource_lock'] + + resource_lock = self.resource_locks_api.update( + context, + id, + lock_data, + ) + return self._view_builder.detail(req, resource_lock) + + +def create_resource(): + return wsgi.Resource(ResourceLocksController()) diff --git a/manila/api/v2/router.py b/manila/api/v2/router.py index 9190eba7eb..b4f5a485ce 100644 --- a/manila/api/v2/router.py +++ b/manila/api/v2/router.py @@ -32,6 +32,7 @@ from manila.api.v2 import availability_zones from manila.api.v2 import messages from manila.api.v2 import quota_class_sets from manila.api.v2 import quota_sets +from manila.api.v2 import resource_locks from manila.api.v2 import services from manila.api.v2 import share_access_metadata from manila.api.v2 import share_accesses @@ -651,3 +652,7 @@ class APIRouter(manila.api.openstack.APIRouter): controller=self.resources['share-backups'], collection={'detail': 'GET'}, member={'action': 'POST'}) + + self.resources["resource_locks"] = resource_locks.create_resource() + mapper.resource("resource-lock", "resource-locks", + controller=self.resources["resource_locks"]) diff --git a/manila/api/views/resource_locks.py b/manila/api/views/resource_locks.py new file mode 100644 index 0000000000..f2c408d2d1 --- /dev/null +++ b/manila/api/views/resource_locks.py @@ -0,0 +1,72 @@ +# 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. + + +from manila.api import common + + +class ViewBuilder(common.ViewBuilder): + """Model a resource lock API response as a python dictionary.""" + + _collection_name = "resource_locks" + + def index(self, request, resource_locks, count=None): + """Show a list of resource locks.""" + return self._list_view(self.detail, + request, + resource_locks, + count=count) + + def detail(self, request, resource_lock): + """Detailed view of a single resource lock.""" + lock_ref = { + 'id': resource_lock.get('id'), + 'user_id': resource_lock.get('user_id'), + 'project_id': resource_lock.get('project_id'), + 'lock_context': resource_lock.get('lock_context'), + 'resource_type': resource_lock.get('resource_type'), + 'resource_id': resource_lock.get('resource_id'), + 'resource_action': resource_lock.get('resource_action'), + 'lock_reason': resource_lock.get('lock_reason'), + 'created_at': resource_lock.get('created_at'), + 'updated_at': resource_lock.get('updated_at'), + 'links': self._get_links(request, resource_lock['id']), + } + return {'resource_lock': lock_ref} + + def _list_view(self, func, request, resource_locks, + coll_name=_collection_name, count=None): + """Provide a view for a list of resource_locks. + + :param func: Function used to format the lock data + :param request: API request + :param resource_locks: List of locks in dictionary format + :param coll_name: Name of collection, used to generate the next link + for a pagination query + :returns: lock data in dictionary format + """ + locks_list = [ + func(request, lock)['resource_lock'] + for lock in resource_locks + ] + locks_links = self._get_collection_links(request, + resource_locks, + coll_name) + locks_dict = dict({"resource_locks": locks_list}) + + if count: + locks_dict['count'] = count + + if locks_links: + locks_dict['resource_locks_links'] = locks_links + + return locks_dict diff --git a/manila/common/constants.py b/manila/common/constants.py index bb7aab04fe..88422a1611 100644 --- a/manila/common/constants.py +++ b/manila/common/constants.py @@ -254,6 +254,16 @@ REPLICATION_TYPE_DR = 'dr' POLICY_EXTEND_BEYOND_MAX_SHARE_SIZE = 'extend_beyond_max_share_size_spec' +RESOURCE_ACTION_DELETE = 'delete' # delete, soft-delete, unmanage + +RESOURCE_LOCK_RESOURCE_TYPES = ( + SHARE_RESOURCE_TYPE, +) + +RESOURCE_LOCK_RESOURCE_ACTIONS = ( + RESOURCE_ACTION_DELETE, +) + class ExtraSpecs(object): diff --git a/manila/context.py b/manila/context.py index 46c058a77d..27fd894745 100644 --- a/manila/context.py +++ b/manila/context.py @@ -58,6 +58,8 @@ class RequestContext(context.RequestContext): self.is_admin = policy.check_is_admin(self) elif self.is_admin and 'admin' not in self.roles: self.roles.append('admin') + # a "service" user's token will contain "service_roles" + self.is_service = kwargs.get('service_roles') or False self.read_deleted = read_deleted self.remote_address = remote_address if not timestamp: diff --git a/manila/db/api.py b/manila/db/api.py index a973eda310..1e1f29f801 100644 --- a/manila/db/api.py +++ b/manila/db/api.py @@ -1822,3 +1822,30 @@ def share_backups_get_all(context, filters=None, limit=None, offset=None, def share_backup_delete(context, backup_id): """Deletes backup with the specified ID.""" return IMPL.share_backup_delete(context, backup_id) + +##################### + + +def resource_lock_create(context, values): + """Create a resource lock.""" + return IMPL.resource_lock_create(context, values) + + +def resource_lock_update(context, lock_id, values): + """Update a resource lock.""" + return IMPL.resource_lock_update(context, lock_id, values) + + +def resource_lock_delete(context, lock_id): + """Delete a resource lock.""" + return IMPL.resource_lock_delete(context, lock_id) + + +def resource_lock_get(context, lock_id): + """Retrieve a resource lock.""" + return IMPL.resource_lock_get(context, lock_id) + + +def resource_lock_get_all(context, **kwargs): + """Retrieve all resource locks.""" + return IMPL.resource_lock_get_all(context, **kwargs) diff --git a/manila/db/migrations/alembic/versions/cb20f743ca7b_add_resource_locks.py b/manila/db/migrations/alembic/versions/cb20f743ca7b_add_resource_locks.py new file mode 100644 index 0000000000..fd7d65a98d --- /dev/null +++ b/manila/db/migrations/alembic/versions/cb20f743ca7b_add_resource_locks.py @@ -0,0 +1,65 @@ +# 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_resource_locks + +Revision ID: cb20f743ca7b +Revises: 9afbe2df4945 +Create Date: 2023-06-23 16:34:36.277477 + +""" + +# revision identifiers, used by Alembic. +revision = 'cb20f743ca7b' +down_revision = '9afbe2df4945' + +from alembic import op +from oslo_log import log +import sqlalchemy as sa + +LOG = log.getLogger(__name__) + + +def upgrade(): + context = op.get_context() + mysql_dl = context.bind.dialect.name == 'mysql' + datetime_type = (sa.dialects.mysql.DATETIME(fsp=6) + if mysql_dl else sa.DateTime) + try: + op.create_table( + 'resource_locks', + sa.Column('id', sa.String(36), primary_key=True, nullable=False), + sa.Column('user_id', sa.String(255), nullable=False), + sa.Column('project_id', sa.String(255), nullable=False), + sa.Column('resource_action', sa.String(255), default='delete'), + sa.Column('resource_type', sa.String(255), nullable=False), + sa.Column('resource_id', sa.String(36), nullable=False), + sa.Column('lock_context', sa.String(16), nullable=False), + sa.Column('lock_reason', sa.String(1023), nullable=True), + sa.Column('created_at', datetime_type), + sa.Column('updated_at', datetime_type), + sa.Column('deleted_at', datetime_type), + sa.Column('deleted', sa.String(36), default='False'), + mysql_engine='InnoDB', + mysql_charset='utf8', + ) + except Exception: + LOG.error("Table resource_locks not created!") + raise + + +def downgrade(): + try: + op.drop_table('resource_locks') + except Exception: + LOG.error("resource_locks table not dropped") + raise diff --git a/manila/db/sqlalchemy/api.py b/manila/db/sqlalchemy/api.py index b5a8cefd91..54f9e94435 100644 --- a/manila/db/sqlalchemy/api.py +++ b/manila/db/sqlalchemy/api.py @@ -7181,3 +7181,108 @@ def share_backup_update(context, backup_id, values): def share_backup_delete(context, backup_id): backup_ref = share_backup_get(context, backup_id) backup_ref.soft_delete(session=context.session, update_status=True) + +############################### + + +@require_context +def _resource_lock_get(context, lock_id): + query = model_query(context, + models.ResourceLock, + read_deleted="no", + project_only="yes") + result = query.filter_by(id=lock_id).first() + if not result: + raise exception.ResourceLockNotFound(lock_id=lock_id) + return result + + +@require_context +@context_manager.writer +def resource_lock_create(context, kwargs): + """Create a resource lock.""" + values = copy.deepcopy(kwargs) + lock_ref = models.ResourceLock() + if not values.get('id'): + values['id'] = uuidutils.generate_uuid() + lock_ref.update(values) + + context.session.add(lock_ref) + + return _resource_lock_get(context, lock_ref['id']) + + +@require_context +@context_manager.writer +def resource_lock_update(context, lock_id, kwargs): + """Update a resource lock.""" + lock_ref = _resource_lock_get(context, lock_id) + lock_ref.update(kwargs) + lock_ref.save(session=context.session) + return lock_ref + + +@require_context +@context_manager.writer +def resource_lock_delete(context, lock_id): + """Delete a resource lock.""" + lock_ref = _resource_lock_get(context, lock_id) + lock_ref.soft_delete(session=context.session) + + +@require_context +@context_manager.reader +def resource_lock_get(context, lock_id): + """Retrieve a resource lock.""" + return _resource_lock_get(context, lock_id) + + +@require_context +@context_manager.reader +def resource_lock_get_all(context, filters=None, limit=None, offset=None, + sort_key='created_at', sort_dir='desc', + show_count=False): + """Retrieve all resource locks. + + If no sort parameters are specified then the returned locks are + sorted by the 'created_at' key in descending order. + + :param context: context to query under + :param limit: maximum number of items to return + :param offset: the number of items to skip from the marker or from the + first element. + :param sort_key: attributes by which results should be sorted. + :param sort_dir: directions in which results should be sorted. + :param filters: dictionary of filters; values that are in lists, tuples, + or sets cause an 'IN' operation, while exact matching + is used for other values, see exact_filter function for + more information + :returns: list of matching resource locks + """ + locks = models.ResourceLock + + # add policy check to allow: all_projects, project_id filters + filters = filters or {} + + query = model_query(context, locks, read_deleted="no") + + project_id = filters.get('project_id') + all_projects = filters.get('all_projects') or filters.get('all_tenants') + if project_id is None and not all_projects: + filters['project_id'] = context.project_id + + legal_filter_keys = ('id', 'user_id', 'resource_id', 'resource_type', + 'lock_context', 'resource_action', 'created_since', + 'created_before', 'lock_reason', 'lock_reason~', + 'project_id') + + query = exact_filter(query, locks, filters, legal_filter_keys) + + count = query.count() if show_count else None + + query = utils.paginate_query(query, locks, limit, + sort_key=sort_key, + sort_dir=sort_dir, + offset=offset) + + return query.all(), count diff --git a/manila/db/sqlalchemy/models.py b/manila/db/sqlalchemy/models.py index 9b6ad66643..70cf1cba0c 100644 --- a/manila/db/sqlalchemy/models.py +++ b/manila/db/sqlalchemy/models.py @@ -1455,6 +1455,31 @@ class Message(BASE, ManilaBase): deleted = Column(String(36), default='False') +class ResourceLock(BASE, ManilaBase): + """Represents a resource lock. + + Resource locks are held by users (or on behalf of users) and prevent + actions to be performed on resources while the lock is present. + """ + __tablename__ = 'resource_locks' + id = Column(String(36), primary_key=True, nullable=False) + user_id = Column(String(255), nullable=False) + project_id = Column(String(255), nullable=False) + # If the lock is held on behalf of the user, but created by 'service' or + # 'admin' users, as opposed to the user themselves ('project') + lock_context = Column(String(10), nullable=False) + # The uuid of the resource being locked. + resource_id = Column(String(36), nullable=False) + # The resource type, a constant dict will hold possible values + resource_type = Column(Enum(*constants.RESOURCE_LOCK_RESOURCE_TYPES), + default=constants.SHARE_RESOURCE_TYPE) + # Action that lock prevents, a constant dict will hold possible values + resource_action = Column(Enum(*constants.RESOURCE_LOCK_RESOURCE_ACTIONS), + default=constants.RESOURCE_ACTION_DELETE) + lock_reason = Column(String(1023), nullable=True) + deleted = Column(String(36), default='False') + + class BackendInfo(BASE, ManilaBase): """Represent Backend Info.""" __tablename__ = "backend_info" diff --git a/manila/exception.py b/manila/exception.py index f86e4f33ca..1fc97bfe49 100644 --- a/manila/exception.py +++ b/manila/exception.py @@ -209,6 +209,10 @@ class MessageNotFound(NotFound): message = _("Message %(message_id)s could not be found.") +class ResourceLockNotFound(NotFound): + message = _("Resource lock %(lock_id)s could not be found.") + + class Found(ManilaException): message = _("Resource was found.") code = 302 diff --git a/manila/lock/__init__.py b/manila/lock/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/manila/lock/api.py b/manila/lock/api.py new file mode 100644 index 0000000000..387c199d2c --- /dev/null +++ b/manila/lock/api.py @@ -0,0 +1,172 @@ +# 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. +""" +Handles all requests related to resource locks. +""" + +from oslo_log import log as logging + +from manila.common import constants +from manila.db import base +from manila import exception +from manila import policy + +LOG = logging.getLogger(__name__) + + +class API(base.Base): + """API for handling resource locks.""" + + resource_get = { + "share": "share_get", + } + + def _get_lock_context(self, context): + if context.is_service: + lock_context = 'service' + elif context.is_admin: + lock_context = 'admin' + else: + lock_context = 'user' + return { + 'lock_context': lock_context, + 'user_id': context.user_id, + 'project_id': context.project_id, + } + + def _check_allow_lock_manipulation(self, context, resource_lock): + """Lock owners may not manipulate a lock if lock_context disallows + + The logic enforced by this method is that user created locks can be + manipulated by all roles, service created locks can be manipulated + by service and admin roles, while admin created locks can only be + manipulated by admin role: + + +------------+------------+--------------+---------+ + | Requester | Lock Owner | Lock Context | Allowed | + +------------+------------+--------------+---------+ + | user | user | user | yes | + | user | user | service | no | + | user | admin | admin | no | + | admin | user | user | yes | + | admin | user | service | yes | + | admin | admin | admin | yes | + | service | user | user | yes | + | service | user | service | yes | + | service | admin | admin | no | + +------------+------------+--------------+---------+ + """ + locked_by = resource_lock['lock_context'] + update_requested_by = self._get_lock_context(context)['lock_context'] + if ((locked_by == 'admin' and update_requested_by != 'admin') + or (locked_by == 'service' and update_requested_by == 'user')): + raise exception.NotAuthorized("Resource lock cannot be " + "manipulated by user. Please " + "contact the administrator.") + + def get(self, context, lock_id): + """Return resource lock with the specified id.""" + return self.db.resource_lock_get(context, lock_id) + + def get_all(self, context, search_opts=None, limit=None, + offset=None, sort_key="created_at", sort_dir="desc", + show_count=False): + """Return resource locks for the given context.""" + LOG.debug("Searching for locks by: %s", search_opts) + + search_opts = search_opts or {} + if 'all_projects' in search_opts: + allow_all_projects = policy.check_policy( + context, + 'resource_lock', + 'get_all_projects', + do_raise=False + ) + LOG.warning("User %s not allowed to query locks across " + "all projects.", context.user_id) + if not allow_all_projects: + search_opts.pop('all_projects') + search_opts.pop('project_id', None) + + locks, count = self.db.resource_lock_get_all( + context, + filters=search_opts, + limit=limit, offset=offset, + sort_key=sort_key, + sort_dir=sort_dir, + show_count=show_count, + ) + + return locks, count + + def create(self, context, resource_id=None, resource_type=None, + resource_action=None, lock_reason=None): + """Create a resource lock with the specified information.""" + get_res_method = getattr(self.db, self.resource_get[resource_type]) + resource = get_res_method(context, resource_id) + policy.check_policy(context, 'resource_lock', 'create', resource) + self._check_resource_state_for_locking(resource_action, resource) + lock_context_data = self._get_lock_context(context) + resource_lock = lock_context_data.copy() + resource_lock.update({ + 'resource_id': resource_id, + 'resource_action': resource_action, + 'lock_reason': lock_reason, + }) + return self.db.resource_lock_create(context, resource_lock) + + def _check_resource_state_for_locking(self, resource_action, resource): + """Check if resource is in a "disallowed" state for locking. + + For example, deletion lock on a "deleting" resource would be futile. + """ + resource_state = resource.get('status', resource.get('state', '')) + disallowed_statuses = () + if resource_action == 'delete': + disallowed_statuses = ( + constants.STATUS_DELETING, + constants.STATUS_ERROR_DELETING, + constants.STATUS_UNMANAGING, + constants.STATUS_MANAGE_ERROR_UNMANAGING, + constants.STATUS_UNMANAGE_ERROR, + constants.STATUS_UNMANAGED, # not possible, future proofing + constants.STATUS_DELETED, # not possible, future proofing + ) + if resource_state in disallowed_statuses: + msg = "Resource status not suitable for locking" + raise exception.InvalidInput(reason=msg) + resource_is_soft_deleted = resource.get('is_soft_deleted', False) + if resource_is_soft_deleted: + msg = "Resource cannot be locked since it has been soft deleted." + raise exception.InvalidInput(reason=msg) + + def update(self, context, lock_id, updates): + """Update a resource lock with the specified information.""" + resource_lock = self.db.resource_lock_get(context, lock_id) + policy.check_policy(context, 'resource_lock', 'update', resource_lock) + self._check_allow_lock_manipulation(context, resource_lock) + if 'resource_action' in updates: + get_res_method = getattr( + self.db, + self.resource_get[resource_lock['resource_type']], + ) + resource = get_res_method(context, resource_lock['resource_id']) + self._check_resource_state_for_locking( + updates['resource_action'], resource) + return self.db.resource_lock_update(context, lock_id, updates) + + def delete(self, context, lock_id): + """Delete resource lock with the specified id.""" + resource_lock = self.db.resource_lock_get(context, lock_id) + policy.check_policy(context, 'resource_lock', 'delete', resource_lock) + self._check_allow_lock_manipulation(context, resource_lock) + self.db.resource_lock_delete(context, lock_id) diff --git a/manila/policies/__init__.py b/manila/policies/__init__.py index 3731980564..4ab48958da 100644 --- a/manila/policies/__init__.py +++ b/manila/policies/__init__.py @@ -21,6 +21,7 @@ from manila.policies import base from manila.policies import message from manila.policies import quota_class_set from manila.policies import quota_set +from manila.policies import resource_lock from manila.policies import scheduler_stats from manila.policies import security_service from manila.policies import service @@ -66,6 +67,7 @@ def list_rules(): service.list_rules(), quota_set.list_rules(), quota_class_set.list_rules(), + resource_lock.list_rules(), share_group_types_spec.list_rules(), share_group_type.list_rules(), share_group_snapshot.list_rules(), diff --git a/manila/policies/base.py b/manila/policies/base.py index 149b26a7e0..dc0711c55f 100644 --- a/manila/policies/base.py +++ b/manila/policies/base.py @@ -25,6 +25,12 @@ from oslo_policy import policy # snapshots). ADMIN = 'rule:context_is_admin' +# This check string is reserved for actions performed by a "service" or the +# "admin" super user. Service users act on behalf of other users and can +# perform privileged service-specific actions. +ADMIN_OR_SERVICE = 'rule:admin_or_service_api' + + # This check string is the primary use case for typical end-users, who are # working with resources that belong within a project (e.g., managing shares or # share replicas). These users don't require all the authorization that @@ -37,13 +43,25 @@ PROJECT_MEMBER = 'rule:project-member' # needs access for auditing or even support. PROJECT_READER = 'rule:project-reader' +# This check string should used to protect user specific resources such as +# resource locks, or access rule restrictions. Users are expendable +# resources, so ensure that other resources can also perform actions to +# avoid orphan resources when users are decommissioned. +OWNER_USER = 'rule:owner-user' + ADMIN_OR_PROJECT_MEMBER = f'({ADMIN}) or ({PROJECT_MEMBER})' ADMIN_OR_PROJECT_READER = f'({ADMIN}) or ({PROJECT_READER})' +ADMIN_OR_SERVICE_OR_PROJECT_READER = (f'({ADMIN_OR_SERVICE}) or ' + f'({PROJECT_READER})') +ADMIN_OR_SERVICE_OR_PROJECT_MEMBER = (f'({ADMIN_OR_SERVICE}) or ' + f'({PROJECT_MEMBER})') +ADMIN_OR_SERVICE_OR_OWNER_USER = f'({OWNER_USER} or {ADMIN_OR_SERVICE})' # Old, "unscoped", deprecated check strings to be removed. Do not use these # in default RBAC any longer. These can be removed after "enforce_scope" # defaults to True in oslo.policy RULE_ADMIN_OR_OWNER = 'rule:admin_or_owner' +RULE_ADMIN_OR_OWNER_USER = 'rule:admin_or_owner_user' RULE_ADMIN_API = 'rule:admin_api' RULE_DEFAULT = 'rule:default' @@ -77,6 +95,18 @@ rules = [ 'project_id:%(project_id)s', description='Project scoped Reader', scope_types=['project']), + policy.RuleDefault( + name='owner-user', + check_str='user_id:%(user_id)s and ' + 'project_id:%(project_id)s', + description='Project scoped user that owns a user specific resource', + scope_types=['project']), + policy.RuleDefault( + "admin_or_service_api", + "role:admin or role:service", + description="A service user or an administrator user.", + scope_types=['project'], + ), # ***Special personas for Manila*** # policy.RuleDefault( @@ -99,6 +129,11 @@ rules = [ name='admin_or_owner', check_str='is_admin:True or project_id:%(project_id)s', description='Administrator or Member of the project'), + policy.RuleDefault( + name='admin_or_owner_user', + check_str='is_admin:True or ' + 'project_id:%(project_id)s and user_id:%(user_id)s', + description='Administrator or owner user of a resource'), policy.RuleDefault( name='default', check_str=RULE_ADMIN_OR_OWNER, diff --git a/manila/policies/resource_lock.py b/manila/policies/resource_lock.py new file mode 100644 index 0000000000..9ca23429f0 --- /dev/null +++ b/manila/policies/resource_lock.py @@ -0,0 +1,154 @@ +# 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. + +from oslo_policy import policy + +from manila.policies import base + + +BASE_POLICY_NAME = 'resource_lock:%s' + +DEPRECATED_REASON = """ +The resource lock API now supports scope and default roles. +""" + +deprecated_lock_get = policy.DeprecatedRule( + name=BASE_POLICY_NAME % 'get', + check_str=base.RULE_DEFAULT, + deprecated_reason=DEPRECATED_REASON, + deprecated_since='2023.2/Bobcat', +) +deprecated_lock_get_all = policy.DeprecatedRule( + name=BASE_POLICY_NAME % 'get_all', + check_str=base.RULE_DEFAULT, + deprecated_reason=DEPRECATED_REASON, + deprecated_since='2023.2/Bobcat', +) +deprecated_lock_get_all_projects = policy.DeprecatedRule( + name=BASE_POLICY_NAME % 'get_all_projects', + check_str=base.RULE_ADMIN_API, + deprecated_reason=DEPRECATED_REASON, + deprecated_since='2023.2/Bobcat', +) +deprecated_lock_create = policy.DeprecatedRule( + name=BASE_POLICY_NAME % 'create', + check_str=base.RULE_DEFAULT, + deprecated_reason=DEPRECATED_REASON, + deprecated_since='2023.2/Bobcat' +) +deprecated_lock_update = policy.DeprecatedRule( + name=BASE_POLICY_NAME % 'update', + check_str=base.RULE_ADMIN_OR_OWNER_USER, + deprecated_reason=DEPRECATED_REASON, + deprecated_since='2023.2/Bobcat', +) +deprecated_lock_delete = policy.DeprecatedRule( + name=BASE_POLICY_NAME % 'delete', + check_str=base.RULE_ADMIN_OR_OWNER_USER, + deprecated_reason=DEPRECATED_REASON, + deprecated_since='2023.2/Bobcat', +) + + +lock_policies = [ + policy.DocumentedRuleDefault( + name=BASE_POLICY_NAME % 'get', + check_str=base.ADMIN_OR_SERVICE_OR_PROJECT_READER, + scope_types=['project'], + description="Get details of a given resource lock.", + operations=[ + { + 'method': 'GET', + 'path': '/resource-locks/{lock_id}' + } + ], + deprecated_rule=deprecated_lock_get, + ), + policy.DocumentedRuleDefault( + name=BASE_POLICY_NAME % 'get_all', + check_str=base.ADMIN_OR_SERVICE_OR_PROJECT_READER, + scope_types=['project'], + description="Get all resource locks.", + operations=[ + { + 'method': 'GET', + 'path': '/resource-locks' + }, + { + 'method': 'GET', + 'path': '/resource-locks?{query}' + } + ], + deprecated_rule=deprecated_lock_get_all, + ), + policy.DocumentedRuleDefault( + name=BASE_POLICY_NAME % 'get_all_projects', + check_str=base.ADMIN_OR_SERVICE, + scope_types=['project'], + description="Get resource locks from all project namespaces.", + operations=[ + { + 'method': 'GET', + 'path': '/resource-locks?all_projects=1' + }, + { + 'method': 'GET', + 'path': '/resource-locks?all_projects=1&' + 'project_id={project_id}' + } + ], + deprecated_rule=deprecated_lock_get_all_projects, + ), + policy.DocumentedRuleDefault( + name=BASE_POLICY_NAME % 'create', + check_str=base.ADMIN_OR_SERVICE_OR_PROJECT_MEMBER, + scope_types=['project'], + description="Create a resource lock.", + operations=[ + { + 'method': 'POST', + 'path': '/resource-locks' + } + ], + deprecated_rule=deprecated_lock_create, + ), + policy.DocumentedRuleDefault( + name=BASE_POLICY_NAME % 'update', + check_str=base.ADMIN_OR_SERVICE_OR_OWNER_USER, + scope_types=['project'], + description="Update a resource lock.", + operations=[ + { + 'method': 'PUT', + 'path': '/resource-locks/{lock_id}' + } + ], + deprecated_rule=deprecated_lock_update, + ), + policy.DocumentedRuleDefault( + name=BASE_POLICY_NAME % 'delete', + check_str=base.ADMIN_OR_SERVICE_OR_OWNER_USER, + scope_types=['project'], + description="Delete a resource lock.", + operations=[ + { + 'method': 'DELETE', + 'path': '/resource-locks/{lock_id}' + } + ], + deprecated_rule=deprecated_lock_delete, + ), +] + + +def list_rules(): + return lock_policies diff --git a/manila/share/api.py b/manila/share/api.py index c899a79fdb..be00d27050 100644 --- a/manila/share/api.py +++ b/manila/share/api.py @@ -19,6 +19,7 @@ """ Handles all requests relating to shares. """ +import functools import json from oslo_config import cfg @@ -127,6 +128,55 @@ class API(base.Base): self.access_helper = access.ShareInstanceAccess(self.db, None) coordination.LOCK_COORDINATOR.start() + def prevent_locked_action_on_share(arg): + """Decorator for preventing a locked method from executing on a share. + + Add this decorator to any API method which takes a RequestContext + object as a first parameter and a share object as the second + parameter. + + Can be used in any of the following forms + @prevent_locked_action_on_share + @prevent_locked_action_on_share('my_action_name') + + :param arg: Can either be the function being decorated or a str + containing the 'action' that we need to check resource locks for. + If no action name is provided, the function name is assumed to be + the action name. + """ + action_name = None + + def check_for_locks(f): + @functools.wraps(f) + def wrapper(self, context, share, *args, **kwargs): + action = action_name or f.__name__ + resource_locks, __ = ( + self.db.resource_lock_get_all( + context.elevated(), + filters={'resource_id': share['id'], + 'resource_action': action, + 'all_projects': True}, + ) + ) + if resource_locks: + msg_payload = { + 'locks': ', '.join( + [lock['id'] for lock in resource_locks] + ), + 'action': action, + } + msg = (f"Resource lock/s [{msg_payload['locks']}] " + f"prevent {action} action.") + raise exception.InvalidShare(msg) + return f(self, context, share, *args, **kwargs) + return wrapper + + if callable(arg): + return check_for_locks(arg) + else: + action_name = arg + return check_for_locks + def _get_all_availability_zones_with_subnets(self, context, share_network_id): compatible_azs_name = [] @@ -1044,6 +1094,7 @@ class API(base.Base): } return request_spec + @prevent_locked_action_on_share('delete') def unmanage(self, context, share): policy.check_policy(context, 'share', 'unmanage') @@ -1239,6 +1290,7 @@ class API(base.Base): context, share, snapshot, active_replica['host'], reservations) @policy.wrap_check_policy('share') + @prevent_locked_action_on_share('delete') def soft_delete(self, context, share): """Soft delete share.""" share_id = share['id'] @@ -1291,6 +1343,7 @@ class API(base.Base): self.db.share_restore(context, share_id) @policy.wrap_check_policy('share') + @prevent_locked_action_on_share def delete(self, context, share, force=False): """Delete share.""" share = self.db.share_get(context, share['id']) diff --git a/manila/tests/api/v2/stubs.py b/manila/tests/api/v2/stubs.py index 1718f1197e..041d175092 100644 --- a/manila/tests/api/v2/stubs.py +++ b/manila/tests/api/v2/stubs.py @@ -45,3 +45,23 @@ def stub_message(id, **kwargs): def stub_message_get(self, context, message_id): return stub_message(message_id) + + +def stub_lock(id, **kwargs): + lock = { + 'id': id, + 'project_id': 'f63f7a159f404cfc8604b7065c609691', + 'user_id': 'e78f4294e3534e00ae176bd989d6a682', + 'resource_id': 'c474badd-f06e-4ff9-ae26-daa00e19867b', + 'resource_action': 'delete', + 'resource_type': 'share', + 'lock_context': 'user', + 'lock_reason': 'for the tests', + 'updated_at': datetime.datetime(2023, 8, 10, 20, 4, 39, + tzinfo=iso8601.UTC), + 'created_at': datetime.datetime(2023, 1, 10, 15, 3, 1, + tzinfo=iso8601.UTC), + } + + lock.update(kwargs) + return lock diff --git a/manila/tests/api/v2/test_resource_locks.py b/manila/tests/api/v2/test_resource_locks.py new file mode 100644 index 0000000000..d3690eda82 --- /dev/null +++ b/manila/tests/api/v2/test_resource_locks.py @@ -0,0 +1,373 @@ +# 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. + +from unittest import mock + +import ddt +from oslo_config import cfg +from oslo_utils import uuidutils +import webob + +from manila.api.v2 import resource_locks +from manila import context +from manila import exception +from manila import policy +from manila import test +from manila.tests.api import fakes +from manila.tests.api.v2 import stubs +from manila.tests import utils as test_utils +from manila import utils + +CONF = cfg.CONF + + +@ddt.ddt +class ResourceLockApiTest(test.TestCase): + def setUp(self): + super(ResourceLockApiTest, self).setUp() + self.controller = resource_locks.ResourceLocksController() + self.maxDiff = None + self.ctxt = context.RequestContext('demo', 'fake', False) + self.req = fakes.HTTPRequest.blank( + '/resource-locks', + version=resource_locks.RESOURCE_LOCKS_MIN_API_VERSION + ) + self.mock_object( + policy, 'check_policy', mock.Mock(return_value=True) + ) + + @ddt.data( + test_utils.annotated('no_body_content', {}), + test_utils.annotated('invalid_body', {'share': 'somedata'}), + test_utils.annotated( + 'invalid_action', { + 'resource_lock': { + 'resource_action': 'invalid_action', + }, + }, + ), + test_utils.annotated( + 'invalid_reason', { + 'resource_lock': { + 'lock_reason': 'xyzzyspoon!' * 94, + }, + }, + ), + test_utils.annotated( + 'disallowed_attributes', { + 'resource_lock': { + 'lock_reason': 'the reason is you', + 'resource_action': 'delete', + 'resource_id': uuidutils.generate_uuid(), + }, + }, + ), + ) + def test__check_body_for_update_invalid(self, body): + self.assertRaises(webob.exc.HTTPBadRequest, + self.controller._check_body, + body, + for_update=True) + + @ddt.data( + test_utils.annotated('no_body_content', {}), + test_utils.annotated('invalid_body', {'share': 'somedata'}), + test_utils.annotated( + 'invalid_action', { + 'resource_lock': { + 'resource_action': 'invalid_action', + }, + }, + ), + test_utils.annotated( + 'invalid_reason', { + 'resource_lock': { + 'lock_reason': 'xyzzyspoon!' * 94, + }, + }, + ), + test_utils.annotated( + 'invalid_resource_id', { + 'resource_lock': { + 'resource_id': 'invalid-id', + 'resource_action': 'delete', + }, + }, + ), + test_utils.annotated( + 'invalid_resource_type', { + 'resource_lock': { + 'resource_id': uuidutils.generate_uuid(), + 'resource_type': 'invalid-resource-type', + }, + }, + ), + test_utils.annotated( + 'empty_resource_type', { + 'resource_lock': { + 'resource_id': uuidutils.generate_uuid(), + 'resource_type': '', + }, + }, + ), + ) + def test__check_body_for_create_invalid(self, body): + self.assertRaises(webob.exc.HTTPBadRequest, + self.controller._check_body, + body) + + @ddt.data( + test_utils.annotated( + 'action_and_lock_reason', { + 'resource_lock': { + 'resource_action': 'delete', + 'lock_reason': 'the reason is you', + }, + }, + ), + test_utils.annotated( + 'lock_reason', { + 'resource_lock': { + 'lock_reason': 'tienes razon', + }, + }, + ), + test_utils.annotated( + 'resource_action', { + 'resource_lock': { + 'resource_action': 'delete', + }, + }, + ), + ) + def test__check_body_for_update(self, body): + result = self.controller._check_body(body, for_update=True) + + self.assertIsNone(result) + + def test__check_body_for_create(self): + body = { + 'resource_lock': { + 'resource_id': uuidutils.generate_uuid(), + 'resource_type': 'share', + }, + } + + result = self.controller._check_body(body) + + self.assertIsNone(result) + + @ddt.data({'created_since': None, 'created_before': None}, + {'created_since': '2222-22-22', 'created_before': 'a_year_ago'}, + {'created_since': 'epoch'}, + {'created_before': 'december'}) + def test_index_invalid_time_filters(self, filters): + url = '/resource-locks?' + for key, value in filters.items(): + url += f'{key}={value}&' + url.rstrip('&') + req = fakes.HTTPRequest.blank( + url, version=resource_locks.RESOURCE_LOCKS_MIN_API_VERSION) + req.environ['manila.context'] = self.ctxt + + self.assertRaises(webob.exc.HTTPBadRequest, + self.controller.index, + req) + + @ddt.data({'limit': 'a', 'offset': 'test'}, + {'limit': -1}, + {'with_count': 'oh-noes', 'limit': 0}) + def test_index_invalid_pagination(self, filters): + url = '/resource-locks?' + for key, value in filters.items(): + url += f'{key}={value}&' + url.rstrip('&') + + req = fakes.HTTPRequest.blank( + url, version=resource_locks.RESOURCE_LOCKS_MIN_API_VERSION) + req.environ['manila.context'] = self.ctxt + + self.assertRaises(webob.exc.HTTPBadRequest, + self.controller.index, + req) + + def test_index(self): + url = ('/resource-locks?sort_dir=asc&sort_key=resource_id&limit=3' + '&offset=1&project_id=f63f7a159f404cfc8604b7065c609691' + '&with_count=1') + req = fakes.HTTPRequest.blank( + url, version=resource_locks.RESOURCE_LOCKS_MIN_API_VERSION) + locks = [ + stubs.stub_lock('68e2e33d-0f0c-49b7-aee3-f0696ab90360'), + stubs.stub_lock('93748a9f-6dfe-4baf-ad4c-b9c82d6063ef'), + stubs.stub_lock('44f8dd68-2eeb-41df-b5d1-9e7654212527'), + ] + self.mock_object(self.controller.resource_locks_api, + 'get_all', + mock.Mock(return_value=(locks, 3))) + + actual_locks = self.controller.index(req) + + expected_filters = { + 'project_id': 'f63f7a159f404cfc8604b7065c609691', + } + self.controller.resource_locks_api.get_all.assert_called_once_with( + utils.IsAMatcher(context.RequestContext), + search_opts=mock.ANY, + limit=3, + offset=1, + sort_key='resource_id', + sort_dir='asc', + show_count=True, + ) + # webob uses a "MultiDict" for request params + actual_filters = {} + call_args = self.controller.resource_locks_api.get_all.call_args[1] + search_opts = call_args['search_opts'] + for key, value in search_opts.dict_of_lists().items(): + actual_filters[key] = value[0] + + self.assertEqual(expected_filters, actual_filters) + self.assertEqual(3, len(actual_locks['resource_locks'])) + for lock in actual_locks['resource_locks']: + for key in locks[0].keys(): + self.assertIn(key, lock) + self.assertIn('links', lock) + self.assertIn('resource_locks_links', actual_locks) + self.assertEqual(3, actual_locks['count']) + + def test_show_not_found(self): + url = '/resource-locks/fake-lock-id' + req = fakes.HTTPRequest.blank( + url, version=resource_locks.RESOURCE_LOCKS_MIN_API_VERSION) + self.mock_object( + self.controller.resource_locks_api, 'get', + mock.Mock(side_effect=exception.ResourceLockNotFound(lock_id='1'))) + self.assertRaises(webob.exc.HTTPNotFound, + self.controller.show, + req, + 'fake-lock-id') + + def test_show(self): + url = '/resource-locks/c6aef27b-f583-48c7-aac1-bd8fb570ce16' + req = fakes.HTTPRequest.blank( + url, version=resource_locks.RESOURCE_LOCKS_MIN_API_VERSION) + expected_lock = stubs.stub_lock( + 'c6aef27b-f583-48c7-aac1-bd8fb570ce16' + ) + self.mock_object( + self.controller.resource_locks_api, + 'get', + mock.Mock(return_value=expected_lock) + ) + + actual_lock = self.controller.show( + req, 'c6aef27b-f583-48c7-aac1-bd8fb570ce16') + self.assertSubDictMatch(expected_lock, actual_lock['resource_lock']) + self.assertIn('links', actual_lock['resource_lock']) + + def test_delete_not_found(self): + url = '/resource-locks/fake-lock-id' + req = fakes.HTTPRequest.blank( + url, version=resource_locks.RESOURCE_LOCKS_MIN_API_VERSION) + self.mock_object( + self.controller.resource_locks_api, + 'delete', + mock.Mock(side_effect=exception.ResourceLockNotFound(lock_id='1')), + ) + self.assertRaises(webob.exc.HTTPNotFound, + self.controller.delete, + req, + 'fake-lock-id') + + def test_delete(self): + url = '/resource-locks/c6aef27b-f583-48c7-aac1-bd8fb570ce16' + req = fakes.HTTPRequest.blank( + url, version=resource_locks.RESOURCE_LOCKS_MIN_API_VERSION) + self.mock_object(self.controller.resource_locks_api, 'delete') + + result = self.controller.delete(req, + 'c6aef27b-f583-48c7-aac1-bd8fb570ce16') + self.assertEqual(204, result.status_int) + + def test_create_no_such_resource(self): + self.mock_object(self.controller, '_check_body') + body = { + 'resource_lock': { + 'resource_id': '27e14086-16e1-445b-ad32-b2ebb07225a8', + 'resource_type': 'share', + }, + } + self.mock_object(self.controller.resource_locks_api, + 'create', + mock.Mock(side_effect=exception.NotFound)) + self.assertRaises(webob.exc.HTTPBadRequest, + self.controller.create, + self.req, + body) + + def test_create(self): + self.mock_object(self.controller, '_check_body') + expected_lock = stubs.stub_lock( + '04512dae-18c2-45b5-bbab-50b775ba6f1d', + lock_reason=None, + ) + body = { + 'resource_lock': { + 'resource_id': expected_lock['resource_id'], + 'resource_type': expected_lock['resource_type'], + }, + } + self.mock_object(self.controller.resource_locks_api, + 'create', + mock.Mock(return_value=expected_lock)) + + actual_lock = self.controller.create(self.req, body)['resource_lock'] + + self.controller.resource_locks_api.create.assert_called_once_with( + utils.IsAMatcher(context.RequestContext), + resource_id=expected_lock['resource_id'], + resource_type=expected_lock['resource_type'], + resource_action='delete', + lock_reason=None, + ) + self.assertSubDictMatch(expected_lock, actual_lock) + self.assertIn('links', actual_lock) + + def test_update(self): + self.mock_object(self.controller, '_check_body') + expected_lock = stubs.stub_lock( + '04512dae-18c2-45b5-bbab-50b775ba6f1d', + lock_reason=None, + ) + self.mock_object(self.controller.resource_locks_api, + 'update', + mock.Mock(return_value=expected_lock)) + + body = { + 'resource_lock': { + 'lock_reason': None + }, + } + + actual_lock = self.controller.update( + self.req, + '04512dae-18c2-45b5-bbab-50b775ba6f1d', + body + )['resource_lock'] + + self.controller.resource_locks_api.update.assert_called_once_with( + utils.IsAMatcher(context.RequestContext), + '04512dae-18c2-45b5-bbab-50b775ba6f1d', + {'lock_reason': None} + ) + self.assertSubDictMatch(expected_lock, actual_lock) diff --git a/manila/tests/db/migrations/alembic/migrations_data_checks.py b/manila/tests/db/migrations/alembic/migrations_data_checks.py index 0887015dfb..8793253153 100644 --- a/manila/tests/db/migrations/alembic/migrations_data_checks.py +++ b/manila/tests/db/migrations/alembic/migrations_data_checks.py @@ -3341,3 +3341,34 @@ class AddServiceState(BaseMigrationChecks): s_table = utils.load_table('services', engine) for s in engine.execute(s_table.select()): self.test_case.assertFalse(hasattr(s, 'state')) + + +@map_to_migration('cb20f743ca7b') +class AddResourceLocks(BaseMigrationChecks): + + def setup_upgrade_data(self, engine): + pass + + def check_upgrade(self, engine, data): + lock_data = { + 'id': uuidutils.generate_uuid(), + 'project_id': uuidutils.generate_uuid(dashed=False), + 'user_id': uuidutils.generate_uuid(dashed=False), + 'resource_id': uuidutils.generate_uuid(), + 'created_at': datetime.datetime(2023, 7, 18, 12, 6, 30), + 'updated_at': None, + 'deleted_at': None, + 'deleted': 'False', + 'resource_type': 'share', + 'resource_action': 'delete', + 'lock_reason': 'xyzzy' * 200, + 'lock_context': 'user', + } + + locks_table = utils.load_table('resource_locks', engine) + engine.execute(locks_table.insert(lock_data)) + + def check_downgrade(self, engine): + self.test_case.assertRaises(sa_exc.NoSuchTableError, + utils.load_table, + 'resource_locks', engine) diff --git a/manila/tests/db/sqlalchemy/test_api.py b/manila/tests/db/sqlalchemy/test_api.py index 5ce5fd56ef..2f15730829 100644 --- a/manila/tests/db/sqlalchemy/test_api.py +++ b/manila/tests/db/sqlalchemy/test_api.py @@ -5438,3 +5438,177 @@ class ShareBackupDatabaseAPITestCase(BaseDatabaseAPITestCase): self.ctxt, 'fake id', {}) + + +class ResourceLocksTestCase(test.TestCase): + """Test case for resource locks.""" + + def setUp(self): + super(ResourceLocksTestCase, self).setUp() + self.user_id = uuidutils.generate_uuid(dashed=False) + self.project_id = uuidutils.generate_uuid(dashed=False) + self.ctxt = context.RequestContext(user_id=self.user_id, + project_id=self.project_id) + + def test_resource_lock_create(self): + lock_data = { + 'resource_id': uuidutils.generate_uuid(), + 'resource_type': 'share', + 'resource_action': 'delete', + 'lock_context': 'user', + 'user_id': self.user_id, + 'project_id': self.project_id, + 'lock_reason': 'xyzzyspoon!', + } + lock = db_api.resource_lock_create(self.ctxt, lock_data) + + self.assertTrue(uuidutils.is_uuid_like(lock['id'])) + self.assertEqual(lock_data['user_id'], lock['user_id']) + self.assertEqual(lock_data['project_id'], lock['project_id']) + self.assertIsNone(lock['updated_at']) + self.assertEqual('False', lock['deleted']) + + def test_resource_lock_update_invalid(self): + self.assertRaises(exception.ResourceLockNotFound, + db_api.resource_lock_update, + self.ctxt, + 'invalid-lock-id', + {'lock_reason': 'yadayada'}) + + def test_resource_lock_update(self): + lock = db_utils.create_lock(project_id=self.project_id) + updated_lock = db_api.resource_lock_update( + self.ctxt, + lock['id'], + {'lock_reason': 'new reason'}, + ) + + self.assertEqual(lock['id'], updated_lock['id']) + self.assertEqual('new reason', updated_lock['lock_reason']) + self.assertEqual(lock['user_id'], updated_lock['user_id']) + self.assertEqual(lock['project_id'], updated_lock['project_id']) + + lock_get = db_api.resource_lock_get(self.ctxt, lock['id']) + + self.assertEqual(lock['id'], lock_get['id']) + self.assertEqual('new reason', lock_get['lock_reason']) + self.assertEqual(lock['user_id'], lock_get['user_id']) + self.assertEqual(lock['project_id'], lock_get['project_id']) + + def test_resource_lock_delete_invalid(self): + self.assertRaises(exception.ResourceLockNotFound, + db_api.resource_lock_delete, + self.ctxt, + 'invalid-lock-id') + + def test_resource_lock_delete(self): + lock = db_utils.create_lock(project_id=self.project_id) + lock_get = db_api.resource_lock_get(self.ctxt, lock['id']) + + return_value = db_api.resource_lock_delete(self.ctxt, lock['id']) + + self.assertIsNone(return_value) + self.assertRaises(exception.ResourceLockNotFound, + db_api._resource_lock_get, + self.ctxt, + lock_get['id']) + + def test_resource_lock_get_invalid(self): + self.assertRaises(exception.ResourceLockNotFound, + db_api.resource_lock_get, + self.ctxt, + 'invalid-lock-id') + + def test_resource_lock_get(self): + lock = db_utils.create_lock(project_id=self.project_id) + + lock_get = db_api.resource_lock_get(self.ctxt, lock['id']) + + self.assertEqual(lock['id'], lock_get['id']) + self.assertEqual('for the tests', lock_get['lock_reason']) + self.assertEqual(lock['user_id'], lock_get['user_id']) + self.assertEqual(lock['project_id'], lock_get['project_id']) + + def test_resource_lock_get_all_basic_filters(self): + user_id_2 = uuidutils.generate_uuid(dashed=False) + project_id_2 = uuidutils.generate_uuid(dashed=False) + + lk_1 = db_utils.create_lock(lock_reason='austin', + user_id=self.user_id, + project_id=self.project_id) + lk_2 = db_utils.create_lock(lock_reason='bexar', + user_id=self.user_id, + project_id=self.project_id) + lk_3 = db_utils.create_lock(lock_reason='cactus', + user_id=self.user_id, + project_id=self.project_id) + lk_4 = db_utils.create_lock(lock_reason='diablo', + user_id=user_id_2, + project_id=project_id_2) + lk_5 = db_utils.create_lock(lock_reason='essex') + + project_locks_limited_offset, count = db_api.resource_lock_get_all( + self.ctxt, limit=2, offset=1, show_count=True) + self.assertEqual(2, len(project_locks_limited_offset)) + self.assertEqual(3, count) + order_expected = [lk_2['id'], lk_1['id']] + self.assertEqual(order_expected, + [lock['id'] for lock in project_locks_limited_offset]) + + all_project_locks, count = db_api.resource_lock_get_all( + self.ctxt, filters={'all_projects': True}, sort_dir='asc') + self.assertEqual(5, len(all_project_locks)) + order_expected = [ + lk_1['id'], lk_2['id'], lk_3['id'], lk_4['id'], lk_5['id'] + ] + self.assertEqual(order_expected, + [lock['id'] for lock in all_project_locks]) + self.assertTrue(lk_5['project_id'] + not in [self.project_id, project_id_2]) + self.assertIsNone(count) + + filtered_locks, count = db_api.resource_lock_get_all( + self.ctxt, filters={'lock_reason~': 'xar'}) + self.assertEqual(1, len(filtered_locks)) + self.assertIsNone(count) + self.assertEqual(lk_2['id'], filtered_locks[0]['id']) + + def test_resource_locks_get_all_time_filters(self): + now = timeutils.utcnow() + lock_1 = db_utils.create_lock( + lock_reason='folsom', + project_id=self.project_id, + created_at=now - datetime.timedelta(seconds=1), + ) + lock_2 = db_utils.create_lock( + lock_reason='grizly', + project_id=self.project_id, + created_at=now + datetime.timedelta(seconds=1), + ) + lock_3 = db_utils.create_lock( + lock_reason='havana', + project_id=self.project_id, + created_at=now + datetime.timedelta(seconds=2), + ) + + filters1 = {'created_before': now} + filters2 = {'created_since': now} + + result1, count1 = db_api.resource_lock_get_all( + self.ctxt, filters=filters1) + result2, count2 = db_api.resource_lock_get_all( + self.ctxt, filters=filters2) + + self.assertEqual(1, len(result1)) + self.assertEqual(lock_1['id'], result1[0]['id']) + self.assertEqual(2, len(result2)) + self.assertEqual([lock_3['id'], lock_2['id']], + [lock['id'] for lock in result2]) + self.assertIsNone(count1) + self.assertIsNone(count2) + + filters1.update(filters2) + result3, count3 = db_api.resource_lock_get_all( + self.ctxt, filters=filters1, show_count=True) + self.assertEqual(0, len(result3)) + self.assertEqual(0, count3) diff --git a/manila/tests/db_utils.py b/manila/tests/db_utils.py index 32152b5fec..2e4e43ee9b 100644 --- a/manila/tests/db_utils.py +++ b/manila/tests/db_utils.py @@ -15,6 +15,8 @@ import copy +from oslo_utils import uuidutils + from manila.common import constants from manila import context from manila import db @@ -326,3 +328,16 @@ def create_backup(share_id, **kwargs): backup.update(kwargs) return db.share_backup_create( context.get_admin_context(), share_id, backup) + + +def create_lock(**kwargs): + lock = { + 'resource_id': uuidutils.generate_uuid(), + 'user_id': uuidutils.generate_uuid(dashed=False), + 'project_id': uuidutils.generate_uuid(dashed=False), + 'lock_context': 'user', + 'lock_reason': 'for the tests', + 'resource_type': 'share', + 'resource_action': 'delete', + } + return _create_db_row(db.resource_lock_create, lock, kwargs) diff --git a/manila/tests/lock/__init__.py b/manila/tests/lock/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/manila/tests/lock/test_api.py b/manila/tests/lock/test_api.py new file mode 100644 index 0000000000..5cff6a0914 --- /dev/null +++ b/manila/tests/lock/test_api.py @@ -0,0 +1,388 @@ +# 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. + +from unittest import mock + +import ddt +from oslo_config import cfg + +from manila.common import constants +from manila import context +from manila import exception +from manila.lock import api as lock_api +from manila import policy +from manila import test +from manila.tests import utils as test_utils +from manila import utils + +CONF = cfg.CONF + + +@ddt.ddt +class ResourceLockApiTest(test.TestCase): + + def setUp(self): + super(ResourceLockApiTest, self).setUp() + self.lock_api = lock_api.API() + self.mock_object(self.lock_api, 'db') + self.ctxt = context.RequestContext('fakeuser', + 'fakeproject', + is_admin=False) + self.mock_object(policy, 'check_policy') + + @ddt.data( + test_utils.annotated( + 'admin_context', + (context.RequestContext('fake', 'fake', is_admin=True), 'admin'), + ), + test_utils.annotated( + 'admin_also_service_context', + (context.RequestContext('fake', 'fake', service_roles=['service'], + is_admin=True), 'service'), + ), + test_utils.annotated( + 'service_context', + (context.RequestContext('fake', 'fake', service_roles=['service'], + is_admin=False), 'service'), + ), + test_utils.annotated( + 'user_context', + (context.RequestContext('fake', 'fake', is_admin=False), 'user') + ), + ) + @ddt.unpack + def test__get_lock_context(self, ctxt, expected_lock_context): + result = self.lock_api._get_lock_context(ctxt) + + self.assertEqual(expected_lock_context, result['lock_context']) + self.assertEqual(('fake', 'fake'), + (result['user_id'], result['project_id'])) + + @ddt.data( + test_utils.annotated( + 'user_manipulating_admin_lock', + (context.RequestContext('fake', 'fake', is_admin=False), 'admin'), + ), + test_utils.annotated( + 'user_manipulating_service_lock', + (context.RequestContext('fake', 'fake', is_admin=False), + 'service'), + ), + test_utils.annotated( + 'service_manipulating_admin_lock', + (context.RequestContext('fake', 'fake', is_admin=False, + service_roles=['service']), 'admin'), + ), + ) + @ddt.unpack + def test__check_allow_lock_manipulation_not_allowed(self, ctxt, lock_ctxt): + self.assertRaises(exception.NotAuthorized, + self.lock_api._check_allow_lock_manipulation, + ctxt, {'lock_context': lock_ctxt}) + + @ddt.data( + test_utils.annotated( + 'user_manipulating_user_lock', + (context.RequestContext('fake', 'fake', is_admin=False), 'user'), + ), + test_utils.annotated( + 'service_manipulating_service_lock', + (context.RequestContext( + 'fake', 'fake', is_admin=False, service_roles=['service']), + 'service'), + ), + test_utils.annotated( + 'service_manipulating_user_lock', + (context.RequestContext( + 'fake', 'fake', is_admin=False, service_roles=['service']), + 'user'), + ), + test_utils.annotated( + 'admin_manipulating_service_lock', + (context.RequestContext('fake', 'fake', is_admin=True), 'service'), + ), + test_utils.annotated( + 'admin_manipulating_user_lock', + (context.RequestContext('fake', 'fake', is_admin=True), 'user'), + ), + ) + @ddt.unpack + def test__check_allow_lock_manipulation_allowed(self, ctxt, lock_ctxt): + + result = self.lock_api._check_allow_lock_manipulation( + ctxt, + {'lock_context': lock_ctxt} + ) + self.assertIsNone(result) + + def test_get_all_all_projects_ignored(self): + self.mock_object(policy, 'check_policy', mock.Mock(return_value=False)) + self.mock_object(self.lock_api.db, 'resource_lock_get_all', + mock.Mock(return_value=('list of locks', None))) + + locks, count = self.lock_api.get_all( + self.ctxt, + search_opts={ + 'all_projects': True, + 'project_id': '5dca5323e33b49fca4a5b261c72e612c', + }) + self.lock_api.db.resource_lock_get_all.assert_called_once_with( + utils.IsAMatcher(context.RequestContext), + filters={}, + limit=None, + offset=None, + sort_key='created_at', + sort_dir='desc', + show_count=False, + ) + self.assertEqual(('list of locks', None), (locks, count)) + + def test_get_all_with_filters(self): + self.mock_object(self.lock_api.db, 'resource_lock_get_all', + mock.Mock(return_value=('list of locks', 4))) + search_opts = { + 'all_projects': True, + 'project_id': '5dca5323e33b49fca4a5b261c72e612c', + 'resource_type': 'snapshot', + } + locks = self.lock_api.get_all( + self.ctxt, + limit=3, + offset=3, + search_opts=search_opts, + show_count=True + ) + self.lock_api.db.resource_lock_get_all.assert_called_once_with( + utils.IsAMatcher(context.RequestContext), + filters=search_opts, + limit=3, + offset=3, + sort_key='created_at', + sort_dir='desc', + show_count=True, + ) + self.assertEqual('list of locks', locks[0]) + self.assertEqual(4, locks[1]) + + def test_create_lock_resource_not_owned_by_user(self): + self.mock_object( + policy, + 'check_policy', + mock.Mock(side_effect=exception.PolicyNotAuthorized( + action="resource_lock:create")), + ) + + self.assertRaises(exception.PolicyNotAuthorized, + self.lock_api.create, + self.ctxt, + resource_id='19529cea-0471-4972-adaa-fee8694b7538', + resource_type='share', + resource_action='delete') + self.lock_api.db.share_get.assert_called_once_with( + utils.IsAMatcher(context.RequestContext), + '19529cea-0471-4972-adaa-fee8694b7538', + ) + self.lock_api.db.resource_lock_create.assert_not_called() + + @ddt.data(constants.STATUS_DELETING, + constants.STATUS_ERROR_DELETING, + constants.STATUS_UNMANAGING, + constants.STATUS_MANAGE_ERROR_UNMANAGING, + constants.STATUS_UNMANAGE_ERROR, + constants.STATUS_UNMANAGED, + constants.STATUS_DELETED) + def test_create_lock_invalid_resource_status(self, status): + self.mock_object(self.lock_api.db, 'resource_lock_create', + mock.Mock(return_value='created_obj')) + self.mock_object(self.lock_api.db, 'share_get', + mock.Mock(return_value={'status': status})) + + self.assertRaises(exception.InvalidInput, + self.lock_api.create, + self.ctxt, + resource_id='7dab6090-1dfd-4829-bbaf-602fcd1c8248', + resource_action='delete', + resource_type='share') + + self.lock_api.db.resource_lock_create.assert_not_called() + + def test_create_lock_invalid_resource_soft_deleted(self): + self.mock_object(self.lock_api.db, 'resource_lock_create', + mock.Mock(return_value='created_obj')) + self.mock_object(self.lock_api.db, 'share_get', + mock.Mock(return_value={'is_soft_deleted': True})) + + self.assertRaises(exception.InvalidInput, + self.lock_api.create, + self.ctxt, + resource_id='0bbf0b62-cb29-4218-920b-3f62faa99ff8', + resource_action='delete', + resource_type='share') + + self.lock_api.db.resource_lock_create.assert_not_called() + + def test_create_lock(self): + self.mock_object(self.lock_api.db, 'resource_lock_create', + mock.Mock(return_value='created_obj')) + mock_share = { + 'id': 'cacac01c-853d-47f3-afcb-da4484bd09a5', + 'status': constants.STATUS_AVAILABLE, + 'is_soft_deleted': False, + } + self.mock_object(self.lock_api.db, 'share_get', + mock.Mock(return_value=mock_share)) + + result = self.lock_api.create( + self.ctxt, + resource_id='cacac01c-853d-47f3-afcb-da4484bd09a5', + resource_action='delete', + resource_type='share', + ) + + self.assertEqual('created_obj', result) + db_create_arg = self.lock_api.db.resource_lock_create.call_args[0][1] + expected_create_arg = { + 'resource_id': 'cacac01c-853d-47f3-afcb-da4484bd09a5', + 'resource_action': 'delete', + 'user_id': 'fakeuser', + 'project_id': 'fakeproject', + 'lock_context': 'user', + 'lock_reason': None, + + } + self.assertEqual(expected_create_arg, db_create_arg) + + @ddt.data(True, False) + def test_update_lock_resource_not_allowed_with_policy_failure( + self, policy_fails): + self.mock_object(self.lock_api.db, 'resource_lock_get', mock.Mock( + return_value={'id': 'd767d3cd-1187-404a-a91f-8b172e0e768e'})) + if policy_fails: + self.mock_object( + policy, + 'check_policy', + mock.Mock( + side_effect=exception.PolicyNotAuthorized( + action='resource_lock:update'), + ), + ) + self.mock_object( + self.lock_api, + '_check_allow_lock_manipulation', + mock.Mock( + side_effect=exception.NotAuthorized + ), + ) + + self.assertRaises(exception.NotAuthorized, + self.lock_api.update, + self.ctxt, + 'd767d3cd-1187-404a-a91f-8b172e0e768e', + {'foo': 'bar'}) + + @ddt.data(constants.STATUS_DELETING, + constants.STATUS_ERROR_DELETING, + constants.STATUS_UNMANAGING, + constants.STATUS_MANAGE_ERROR_UNMANAGING, + constants.STATUS_UNMANAGE_ERROR, + constants.STATUS_UNMANAGED, + constants.STATUS_DELETED) + def test_update_invalid_resource_status(self, status): + lock = { + 'id': 'd767d3cd-1187-404a-a91f-8b172e0e768e', + 'resource_id': '266cf54f-f9cf-4d6c-94f3-7b67f00e0465', + 'resource_action': 'something', + 'resource_type': 'share', + } + self.mock_object(self.lock_api.db, 'resource_lock_get', + mock.Mock(return_value=lock)) + self.mock_object(self.lock_api, '_check_allow_lock_manipulation') + self.mock_object(self.lock_api.db, + 'share_get', + mock.Mock(return_value={'status': status})) + + self.assertRaises(exception.InvalidInput, + self.lock_api.update, + self.ctxt, + 'd767d3cd-1187-404a-a91f-8b172e0e768e', + {'resource_action': 'delete'}) + + self.lock_api.db.resource_lock_update.assert_not_called() + + def test_update(self): + self.mock_object(self.lock_api.db, 'resource_lock_get', mock.Mock( + return_value={'id': 'd767d3cd-1187-404a-a91f-8b172e0e768e'})) + self.mock_object(self.lock_api, '_check_allow_lock_manipulation') + self.mock_object(self.lock_api.db, 'resource_lock_update', + mock.Mock(return_value='updated_obj')) + + result = self.lock_api.update( + self.ctxt, + 'd767d3cd-1187-404a-a91f-8b172e0e768e', + {'foo': 'bar'}, + ) + + self.assertEqual('updated_obj', result) + self.lock_api.db.resource_lock_update.assert_called_once_with( + utils.IsAMatcher(context.RequestContext), + 'd767d3cd-1187-404a-a91f-8b172e0e768e', + {'foo': 'bar'}, + ) + + @ddt.data(True, False) + def test_delete_not_allowed_with_policy_failure(self, policy_fails): + self.mock_object(self.lock_api.db, 'resource_lock_get', mock.Mock( + return_value={'id': 'd767d3cd-1187-404a-a91f-8b172e0e768e'})) + if policy_fails: + self.mock_object( + policy, + 'check_policy', + mock.Mock( + side_effect=exception.PolicyNotAuthorized( + action='resource_lock:delete'), + ), + ) + self.mock_object( + self.lock_api, + '_check_allow_lock_manipulation', + mock.Mock( + side_effect=exception.NotAuthorized + ), + ) + + self.assertRaises(exception.NotAuthorized, + self.lock_api.delete, + self.ctxt, + 'd767d3cd-1187-404a-a91f-8b172e0e768e') + + policy.check_policy.assert_called_once_with( + utils.IsAMatcher(context.RequestContext), + 'resource_lock', + 'delete', + {'id': 'd767d3cd-1187-404a-a91f-8b172e0e768e'}, + ) + self.assertEqual(not policy_fails, + self.lock_api._check_allow_lock_manipulation.called) + self.lock_api.db.resource_lock_delete.assert_not_called() + + def test_delete(self): + self.mock_object(self.lock_api.db, 'resource_lock_get', mock.Mock( + return_value={'id': 'd767d3cd-1187-404a-a91f-8b172e0e768e'})) + self.mock_object(self.lock_api, '_check_allow_lock_manipulation') + + result = self.lock_api.delete(self.ctxt, + 'd767d3cd-1187-404a-a91f-8b172e0e768e') + self.assertIsNone(result) + self.lock_api.db.resource_lock_delete.assert_called_once_with( + utils.IsAMatcher(context.RequestContext), + 'd767d3cd-1187-404a-a91f-8b172e0e768e' + ) diff --git a/manila/tests/share/test_api.py b/manila/tests/share/test_api.py index 2054298444..5a1c6eb058 100644 --- a/manila/tests/share/test_api.py +++ b/manila/tests/share/test_api.py @@ -113,6 +113,8 @@ class ShareAPITestCase(test.TestCase): self.scheduler_rpcapi = mock.Mock() self.share_rpcapi = mock.Mock() self.api = share.API() + self.mock_object(self.api.db, 'resource_lock_get_all', + mock.Mock(return_value=([], None))) self.mock_object(self.api, 'scheduler_rpcapi', self.scheduler_rpcapi) self.mock_object(self.api, 'share_rpcapi', self.share_rpcapi) self.mock_object(quota.QUOTAS, 'reserve', @@ -1516,6 +1518,31 @@ class ShareAPITestCase(test.TestCase): self.assertRaises(exception.ShareBusyException, self.api.unmanage, self.context, share) + def test_unmanage_locked_share(self): + self.mock_object( + self.api.db, + 'resource_lock_get_all', + mock.Mock(return_value=([{'id': 'l1'}, {'id': 'l2'}], None)) + ) + share = db_utils.create_share( + id='fakeid', + host='fake', + size='1', + status=constants.STATUS_AVAILABLE, + user_id=self.context.user_id, + project_id=self.context.project_id, + task_state=None) + self.mock_object(db_api, 'share_update', mock.Mock()) + + self.assertRaises(exception.InvalidShare, + self.api.unmanage, + self.context, + share) + + # lock check decorator executed first, nothing else is invoked + self.share_rpcapi.unmanage_share.assert_not_called() + db_api.share_update.assert_not_called() + @mock.patch.object(quota.QUOTAS, 'reserve', mock.Mock(return_value='reservation')) @mock.patch.object(quota.QUOTAS, 'commit', mock.Mock()) @@ -2601,6 +2628,23 @@ class ShareAPITestCase(test.TestCase): self.api.delete(self.context, share) + def test_delete_locked_share(self): + self.mock_object( + self.api.db, + 'resource_lock_get_all', + mock.Mock(return_value=([{'id': 'l1'}, {'id': 'l2'}], None)) + ) + share = self._setup_delete_mocks('available') + + self.assertRaises(exception.InvalidShare, + self.api.delete, + self.context, + share) + + # lock check decorator executed first, nothing else is invoked + self.api.delete_instance.assert_not_called() + db_api.share_snapshot_get_all_for_share.assert_not_called() + @ddt.data({'status': constants.STATUS_AVAILABLE, 'force': False}, {'status': constants.STATUS_ERROR, 'force': True}) @ddt.unpack @@ -6630,6 +6674,24 @@ class ShareAPITestCase(test.TestCase): self.assertRaises(exception.InvalidShare, self.api.soft_delete, self.context, share) + def test_soft_delete_locked_share(self): + self.mock_object( + self.api.db, + 'resource_lock_get_all', + mock.Mock(return_value=([{'id': 'l1'}, {'id': 'l2'}], None)) + ) + share = self._setup_delete_mocks('available') + self.mock_object(db_api, 'share_soft_delete') + + self.assertRaises(exception.InvalidShare, + self.api.soft_delete, + self.context, + share) + + # lock check decorator executed first, nothing else is invoked + db_api.share_soft_delete.assert_not_called() + db_api.share_snapshot_get_all_for_share.assert_not_called() + def test_soft_delete_share(self): share = fakes.fake_share(id='fake_id', status=constants.STATUS_AVAILABLE, diff --git a/manila/tests/test_context.py b/manila/tests/test_context.py index d95145336d..dc1fd7514b 100644 --- a/manila/tests/test_context.py +++ b/manila/tests/test_context.py @@ -35,6 +35,13 @@ class ContextTestCase(test.TestCase): roles=['admin', 'weasel']) self.assertTrue(ctxt.is_admin) + def test_request_context_sets_is_service(self): + ctxt = context.RequestContext('111', + '222', + roles=['service', 'admin'], + service_roles=['service']) + self.assertTrue(ctxt.is_service) + def test_request_context_sets_is_admin_upcase(self): ctxt = context.RequestContext('111', '222', diff --git a/manila/tests/test_exception.py b/manila/tests/test_exception.py index 8c11eadae2..00d52cfec0 100644 --- a/manila/tests/test_exception.py +++ b/manila/tests/test_exception.py @@ -564,6 +564,13 @@ class ManilaExceptionResponseCode404(test.TestCase): self.assertEqual(404, e.code) self.assertIn(share_id, e.msg) + def test_resource_lock_not_found(self): + # verify response code for exception.ResourceLockNotFound + lock_id = "fake_lock_id" + e = exception.ResourceLockNotFound(lock_id=lock_id) + self.assertEqual(404, e.code) + self.assertIn(lock_id, e.msg) + class ManilaExceptionResponseCode413(test.TestCase): diff --git a/releasenotes/notes/bp-allow-locking-shares-against-deletion-5a715292e720a254.yaml b/releasenotes/notes/bp-allow-locking-shares-against-deletion-5a715292e720a254.yaml new file mode 100644 index 0000000000..42bfb458c6 --- /dev/null +++ b/releasenotes/notes/bp-allow-locking-shares-against-deletion-5a715292e720a254.yaml @@ -0,0 +1,9 @@ +--- +features: + - | + Added new API endpoints and methods to create, retrieve, update and + delete resource locks. Resource locks can be used to restrict certain + actions from occurring on the resource. Currently users can prevent + deletion of a share (including soft-deletion, transfer and unmanage + operations) by creating a resource lock against the share. In future + releases, more resource actions may be supported by this feature.