From dd630c3929af6829a1f4ddb72b27d353e21977e7 Mon Sep 17 00:00:00 2001 From: Alex Meade Date: Fri, 6 May 2016 09:33:09 -0400 Subject: [PATCH] User Messages For quite some time, OpenStack services have wanted to be able to send messages to API end users (by user I do not mean the operator, but the user that is interacting with the client). This patch implements basic user messages with the following APIs. GET /messages GET /messages/ DELETE /messages/ Implements the basic /messages resource and tempest tests The patch is aligned with related cinder patch where possible: I8a635a07ed6ff93ccb71df8c404c927d1ecef005 DocImpact APIImpact Needed-By: I5ffb840a271c518f62ee1accfd8e20a97f45594d Needed-By: I9ce096eebda3249687268e361b7141dea4032b57 Needed-By: Ic7d25a144905a39c56ababe8bd666b01bc0d0aef Partially-implements: blueprint user-messages Co-Authored-By: Jan Provaznik Change-Id: Ia0cc524e0bfb2ca5e495e575e17e9911c746690b --- api-ref/source/index.rst | 1 + api-ref/source/parameters.yaml | 111 +++++++++++ .../samples/user-message-show-response.json | 24 +++ .../samples/user-messages-list-response.json | 26 +++ api-ref/source/user-messages.inc | 119 +++++++++++ doc/source/devref/index.rst | 1 + doc/source/devref/user_messages.rst | 70 +++++++ etc/manila/policy.json | 6 +- manila/api/openstack/api_version_request.py | 3 +- .../openstack/rest_api_version_history.rst | 4 + manila/api/v2/messages.py | 95 +++++++++ manila/api/v2/router.py | 5 + manila/api/views/messages.py | 68 +++++++ manila/db/api.py | 24 +++ .../238720805ce1_add_messages_table.py | 66 +++++++ manila/db/sqlalchemy/api.py | 63 ++++++ manila/db/sqlalchemy/models.py | 25 +++ manila/exception.py | 4 + manila/message/__init__.py | 0 manila/message/api.py | 85 ++++++++ manila/message/message_field.py | 64 ++++++ manila/message/message_levels.py | 15 ++ manila/opts.py | 2 + manila/scheduler/manager.py | 26 ++- manila/tests/api/v2/stubs.py | 47 +++++ manila/tests/api/v2/test_messages.py | 186 ++++++++++++++++++ .../alembic/migrations_data_checks.py | 32 +++ manila/tests/db/sqlalchemy/test_api.py | 86 ++++++++ manila/tests/db_utils.py | 10 + manila/tests/message/__init__.py | 0 manila/tests/message/test_api.py | 92 +++++++++ manila/tests/message/test_message_field.py | 62 ++++++ manila/tests/policy.json | 6 +- manila/tests/scheduler/test_manager.py | 14 +- manila_tempest_tests/config.py | 2 +- .../services/share/v2/json/shares_client.py | 44 +++++ .../tests/api/admin/test_user_messages.py | 103 ++++++++++ .../api/admin/test_user_messages_negative.py | 58 ++++++ manila_tempest_tests/tests/api/base.py | 19 ++ .../user-messages-api-589ee7d68ccba70c.yaml | 7 + 40 files changed, 1660 insertions(+), 15 deletions(-) create mode 100644 api-ref/source/samples/user-message-show-response.json create mode 100644 api-ref/source/samples/user-messages-list-response.json create mode 100644 api-ref/source/user-messages.inc create mode 100644 doc/source/devref/user_messages.rst create mode 100644 manila/api/v2/messages.py create mode 100644 manila/api/views/messages.py create mode 100644 manila/db/migrations/alembic/versions/238720805ce1_add_messages_table.py create mode 100644 manila/message/__init__.py create mode 100644 manila/message/api.py create mode 100644 manila/message/message_field.py create mode 100644 manila/message/message_levels.py create mode 100644 manila/tests/api/v2/stubs.py create mode 100644 manila/tests/api/v2/test_messages.py create mode 100644 manila/tests/message/__init__.py create mode 100644 manila/tests/message/test_api.py create mode 100644 manila/tests/message/test_message_field.py create mode 100644 manila_tempest_tests/tests/api/admin/test_user_messages.py create mode 100644 manila_tempest_tests/tests/api/admin/test_user_messages_negative.py create mode 100644 releasenotes/notes/user-messages-api-589ee7d68ccba70c.yaml diff --git a/api-ref/source/index.rst b/api-ref/source/index.rst index 393bb31f..290173ea 100644 --- a/api-ref/source/index.rst +++ b/api-ref/source/index.rst @@ -25,6 +25,7 @@ Shared File Systems API .. include:: availability-zones.inc .. include:: os-share-manage.inc .. include:: quota-sets.inc +.. include:: user-messages.inc ====================================== Shared File Systems API (EXPERIMENTAL) diff --git a/api-ref/source/parameters.yaml b/api-ref/source/parameters.yaml index f58f9e49..0137492e 100644 --- a/api-ref/source/parameters.yaml +++ b/api-ref/source/parameters.yaml @@ -50,6 +50,12 @@ export_location_id_path: in: path required: false type: string +message_id: + description: | + The UUID of the message. + in: path + required: false + type: string security_service_id_path: description: | The UUID of the security service. @@ -121,6 +127,12 @@ tenant_id_path: type: string # variables in query +action_id: + in: query + required: false + type: string + description: > + The ID of the action during which the message was created. all_tenants: description: | (Admin only). Defines whether to list shares for @@ -206,6 +218,12 @@ consistency_group_id_5: in: query required: false type: string +detail_id: + in: query + required: false + type: string + description: > + The ID of the message detail. export_location_id_query: description: | The export location UUID that can be used to filter shares or @@ -275,6 +293,12 @@ media_types: in: query required: false type: object +message_level: + in: query + required: false + type: string + description: > + The message level. metadata_1: description: | One or more metadata key-value pairs, as a @@ -316,6 +340,30 @@ project_id_6: in: query required: false type: string +project_id_messages: + description: | + The UUID of the project for which the message was created. + in: query + required: false + type: string +request_id: + description: | + The UUID of the request during which the message was created. + in: query + required: false + type: string +resource_id: + description: | + The UUID of the resource for which the message was created. + in: query + required: false + type: string +resource_type: + description: | + The type of the resource for which the message was created. + in: query + required: false + type: string service_binary_query: description: | The service binary name. Default is the base name @@ -399,6 +447,15 @@ sort_key: in: query required: false type: string +sort_key_messages: + description: | + The key to sort a list of messages. A valid value + is ``id``, ``project_id``, ``request_id``, ``resource_type``, + ``action_id``, ``detail_id``, ``resource_id``, ``message_level``, + ``expires_at``, ``created_at``. + in: query + required: false + type: string state_2: description: | The current state of the service. A valid value @@ -577,6 +634,12 @@ access_type: in: body required: true type: string +action_id_body: + in: body + required: true + type: string + description: > + The ID of the action during which the message was created. alias: description: | The alias for the extension. For example, @@ -1205,6 +1268,12 @@ description_9: in: body required: true type: string +detail_id_body: + in: body + required: true + type: string + description: > + The ID of the message detail. disabled: description: | Indicates whether the service is disabled. @@ -1782,6 +1851,24 @@ maxTotalSnapshotGigabytes: in: body required: true type: integer +message_level_body: + in: body + required: true + type: string + description: > + The message level. +message_links: + description: | + The message links. + in: body + required: true + type: array +message_members_links: + description: | + The message member links. + in: body + required: true + type: array metadata: description: | One or more metadata key and value pairs as a @@ -2114,6 +2201,12 @@ project_id_9: in: body required: true type: string +project_id_messages_body: + description: | + The UUID of the project for which the message was created. + in: body + required: true + type: string protocol: description: | The Shared File Systems protocol of the share to @@ -2292,6 +2385,12 @@ replication_type: in: body required: false type: string +request_id_body: + description: | + The UUID of the request during which the message was created. + in: body + required: true + type: string required_extra_specs: description: | The required extra specifications for the share @@ -2312,6 +2411,18 @@ reset_status: in: body required: true type: object +resource_id_body: + description: | + The UUID of the resource for which the message was created. + in: body + required: true + type: string +resource_type_body: + description: | + The type of the resource for which the message was created. + in: body + required: true + type: string security_service_created_at: description: | The date and time stamp when the security service was created. diff --git a/api-ref/source/samples/user-message-show-response.json b/api-ref/source/samples/user-message-show-response.json new file mode 100644 index 00000000..6e0e111c --- /dev/null +++ b/api-ref/source/samples/user-message-show-response.json @@ -0,0 +1,24 @@ +{ + "message": { + "links": [ + { + "href": "http://192.168.122.180:8786/v2/2e3de76b49b444fd9dc7ca9f7048ce6b/messages/4b319d29-d5b7-4b6e-8e7c-8d6e53f3c3d5", + "rel": "self" + }, { + "href": "http://192.168.122.180:8786/2e3de76b49b444fd9dc7ca9f7048ce6b/messages/4b319d29-d5b7-4b6e-8e7c-8d6e53f3c3d5", + "rel": "bookmark" + } + ], + "resource_id": "351cc796-2d79-4a08-b878-a8ed933b6b68", + "message_level": "ERROR", + "user_message": "allocate host: No storage could be allocated for this share request. Trying again with a different size or share type may succeed.", + "expires_at": "2017-07-10T10:27:43.000000", + "id": "4b319d29-d5b7-4b6e-8e7c-8d6e53f3c3d5", + "created_at": "2017-07-10T10:26:43.000000", + "detail_id": "002", + "request_id": "req-24e7ccb6-a7d5-4ddd-a8e4-d8f72a4509c8", + "project_id": "2e3de76b49b444fd9dc7ca9f7048ce6b", + "resource_type": "SHARE", + "action_id": "001" + } +} diff --git a/api-ref/source/samples/user-messages-list-response.json b/api-ref/source/samples/user-messages-list-response.json new file mode 100644 index 00000000..bcf03a84 --- /dev/null +++ b/api-ref/source/samples/user-messages-list-response.json @@ -0,0 +1,26 @@ +{ + "messages": [ + { + "links": [ + { + "href": "http://192.168.122.180:8786/v2/2e3de76b49b444fd9dc7ca9f7048ce6b/messages/4b319d29-d5b7-4b6e-8e7c-8d6e53f3c3d5", + "rel": "self" + }, { + "href": "http://192.168.122.180:8786/2e3de76b49b444fd9dc7ca9f7048ce6b/messages/4b319d29-d5b7-4b6e-8e7c-8d6e53f3c3d5", + "rel": "bookmark" + } + ], + "id": "4b319d29-d5b7-4b6e-8e7c-8d6e53f3c3d5", + "resource_id": "351cc796-2d79-4a08-b878-a8ed933b6b68", + "message_level": "ERROR", + "user_message": "allocate host: No storage could be allocated for this share request. Trying again with a different size or share type may succeed.", + "expires_at": "2017-07-10T10:27:43.000000", + "created_at": "2017-07-10T10:26:43.000000", + "detail_id": "002", + "request_id": "req-24e7ccb6-a7d5-4ddd-a8e4-d8f72a4509c8", + "project_id": "2e3de76b49b444fd9dc7ca9f7048ce6b", + "resource_type": "SHARE", + "action_id": "001" + } + ] +} diff --git a/api-ref/source/user-messages.inc b/api-ref/source/user-messages.inc new file mode 100644 index 00000000..b7bde4c6 --- /dev/null +++ b/api-ref/source/user-messages.inc @@ -0,0 +1,119 @@ +.. -*- rst -*- + +============================== +User messages (since API 2.37) +============================== + +Lists, shows and deletes user messages. + + +List user messages +================== + +.. rest_method:: GET /v2/{tenant_id}/messages + +Lists all user messages. + + +Normal response codes: 200 +Error response codes: badRequest(400), unauthorized(401), forbidden(403) + +Request +------- + +.. rest_parameters:: parameters.yaml + + - tenant_id: tenant_id_1 + - limit: limit + - offset: offset + - sort_key: sort_key_messages + - sort_dir: sort_dir + - action_id: action_id + - detail_id: detail_id + - message_level: message_level + - project_id: project_id_messages + - request_id: request_id + - resource_id: resource_id + - resource_type: resource_type + +Response parameters +------------------- + +.. rest_parameters:: parameters.yaml + + - action_id: action_id_body + - detail_id: detail_id_body + - message_level: message_level_body + - project_id: project_id_messages_body + - request_id: request_id_body + - resource_id: resource_id_body + - resource_type: resource_type_body + - message_members_links: message_members_links + + +Response example +---------------- + +.. literalinclude:: samples/user-messages-list-response.json + :language: javascript + + +Show user message details +========================= + +.. rest_method:: GET /v2/{tenant_id}/messages/{message_id} + +Shows details for a user message. + +Normal response codes: 200 +Error response codes: badRequest(400), unauthorized(401), forbidden(403), +itemNotFound(404) + +Request +------- + +.. rest_parameters:: parameters.yaml + + - tenant_id: tenant_id_1 + - message_id: message_id + +Response parameters +------------------- + +.. rest_parameters:: parameters.yaml + + - action_id: action_id_body + - detail_id: detail_id_body + - message_level: message_level_body + - project_id: project_id_messages_body + - request_id: request_id_body + - resource_id: resource_id_body + - resource_type: resource_type_body + - message_links: message_links + + +Response example +---------------- + +.. literalinclude:: samples/user-message-show-response.json + :language: javascript + + +Delete message +============== + +.. rest_method:: DELETE /v2/{tenant_id}/messages/{message_id} + +Deletes a user message. + +Normal response codes: 202 +Error response codes: badRequest(400), unauthorized(401), forbidden(403), +itemNotFound(404) + +Request +------- + +.. rest_parameters:: parameters.yaml + + - tenant_id: tenant_id_1 + - message_id: message_id diff --git a/doc/source/devref/index.rst b/doc/source/devref/index.rst index 2bdc4621..d146469e 100644 --- a/doc/source/devref/index.rst +++ b/doc/source/devref/index.rst @@ -35,6 +35,7 @@ Programming HowTos and Tutorials adding_release_notes commit_message_tags guru_meditation_report + user_messages Background Concepts for manila diff --git a/doc/source/devref/user_messages.rst b/doc/source/devref/user_messages.rst new file mode 100644 index 00000000..26aba77a --- /dev/null +++ b/doc/source/devref/user_messages.rst @@ -0,0 +1,70 @@ +User Messages +============= + +User messages are a way to inform users about the state of asynchronous +operations. One example would be notifying the user of why a share +provisioning request failed. These messages can be requested via the +`/messages` API. All user visible messages must be defined in the permitted +messages module in order to prevent sharing sensitive information with users. + + +Example message generation:: + + from manila import context + from manila.message import api as message_api + from manila.message import message_field + + self.message_api = message_api.API() + + context = context.RequestContext() + project_id = '6c430ede-9476-4128-8838-8d3929ced223' + share_id = 'f292cc0c-54a7-4b3b-8174-d2ff82d87008' + + self.message_api.create( + context, + message_field.Actions.CREATE, + project_id, + resource_type=message_field.Resource.SHARE, + resource_id=SHARE_id, + detail=message_field.Detail.NO_VALID_HOST) + +Will produce the following:: + + GET /v2/6c430ede-9476-4128-8838-8d3929ced223/messages + { + "messages": [ + { + "id": "5429fffa-5c76-4d68-a671-37a8e24f37cf", + "action_id": "001", + "detail_id": "002", + "user_message": "create: No storage could be allocated for this share " + "request. Trying again with a different size " + "or share type may succeed."", + "message_level": "ERROR", + "resource_type": "SHARE", + "resource_id": "f292cc0c-54a7-4b3b-8174-d2ff82d87008", + "created_at": 2015-08-27T09:49:58-05:00, + "expires_at": 2015-09-26T09:49:58-05:00, + "request_id": "req-936666d2-4c8f-4e41-9ac9-237b43f8b848", + } + ] + } + + + +The Message API Module +---------------------- + +.. automodule:: manila.message.api + :noindex: + :members: + :undoc-members: + +The Permitted Messages Module +----------------------------- + +.. automodule:: manila.message.message_field + :noindex: + :members: + :undoc-members: + :show-inheritance: diff --git a/etc/manila/policy.json b/etc/manila/policy.json index b3e8c9e6..dc91ebf1 100644 --- a/etc/manila/policy.json +++ b/etc/manila/policy.json @@ -155,5 +155,9 @@ "share_group_types_spec:update": "rule:admin_api", "share_group_types_spec:show": "rule:admin_api", "share_group_types_spec:index": "rule:admin_api", - "share_group_types_spec:delete": "rule:admin_api" + "share_group_types_spec:delete": "rule:admin_api", + + "message:delete": "rule:default", + "message:get": "rule:default", + "message:get_all": "rule:default" } diff --git a/manila/api/openstack/api_version_request.py b/manila/api/openstack/api_version_request.py index 91a6be4d..8ed46fda 100644 --- a/manila/api/openstack/api_version_request.py +++ b/manila/api/openstack/api_version_request.py @@ -106,6 +106,7 @@ REST_API_VERSION_HISTORY = """ and export_location_path. * 2.36 - Added like filter support in ``shares``, ``snapshots``, ``share-networks``, ``share-groups`` list APIs. + * 2.37 - Added /messages APIs. """ @@ -113,7 +114,7 @@ REST_API_VERSION_HISTORY = """ # 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.36" +_MAX_API_VERSION = "2.37" 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 b687b754..8b4994ea 100644 --- a/manila/api/openstack/rest_api_version_history.rst +++ b/manila/api/openstack/rest_api_version_history.rst @@ -210,3 +210,7 @@ user documentation. ---- Added like filter support in ``shares``, ``snapshots``, ``share-networks``, ``share-groups`` list APIs. + +2.37 +---- + Added /messages APIs. diff --git a/manila/api/v2/messages.py b/manila/api/v2/messages.py new file mode 100644 index 00000000..e9e0cae2 --- /dev/null +++ b/manila/api/v2/messages.py @@ -0,0 +1,95 @@ +# 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 messages API controller module. + +This module handles the following requests: +GET /messages +GET /messages/ +DELETE /messages/ +""" + +import webob +from webob import exc + +from manila.api import common +from manila.api.openstack import wsgi +from manila.api.views import messages as messages_view +from manila import exception +from manila.message import api as message_api + +MESSAGES_BASE_MICRO_VERSION = '2.37' + + +class MessagesController(wsgi.Controller): + """The User Messages API controller for the OpenStack API.""" + + _view_builder_class = messages_view.ViewBuilder + resource_name = 'message' + + def __init__(self): + self.message_api = message_api.API() + super(MessagesController, self).__init__() + + @wsgi.Controller.api_version(MESSAGES_BASE_MICRO_VERSION) + @wsgi.Controller.authorize('get') + def show(self, req, id): + """Return the given message.""" + context = req.environ['manila.context'] + + try: + message = self.message_api.get(context, id) + except exception.MessageNotFound as error: + raise exc.HTTPNotFound(explanation=error.msg) + + return self._view_builder.detail(req, message) + + @wsgi.Controller.api_version(MESSAGES_BASE_MICRO_VERSION) + @wsgi.Controller.authorize + @wsgi.action("delete") + def delete(self, req, id): + """Delete a message.""" + context = req.environ['manila.context'] + + try: + message = self.message_api.get(context, id) + self.message_api.delete(context, message) + except exception.MessageNotFound as error: + raise exc.HTTPNotFound(explanation=error.msg) + + return webob.Response(status_int=204) + + @wsgi.Controller.api_version(MESSAGES_BASE_MICRO_VERSION) + @wsgi.Controller.authorize('get_all') + def index(self, req): + """Returns a list of messages, transformed through view builder.""" + context = req.environ['manila.context'] + + search_opts = {} + search_opts.update(req.GET) + + # Remove keys that are not related to message attrs + search_opts.pop('limit', None) + search_opts.pop('marker', None) + sort_key = search_opts.pop('sort_key', 'created_at') + sort_dir = search_opts.pop('sort_dir', 'desc') + + messages = self.message_api.get_all( + context, search_opts=search_opts, sort_dir=sort_dir, + sort_key=sort_key) + limited_list = common.limited(messages, req) + + return self._view_builder.index(req, limited_list) + + +def create_resource(): + return wsgi.Resource(MessagesController()) diff --git a/manila/api/v2/router.py b/manila/api/v2/router.py index 462b2143..f5a2d65e 100644 --- a/manila/api/v2/router.py +++ b/manila/api/v2/router.py @@ -31,6 +31,7 @@ from manila.api.v1 import share_servers from manila.api.v1 import share_types_extra_specs from manila.api.v1 import share_unmanage 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 services @@ -410,3 +411,7 @@ class APIRouter(manila.api.openstack.APIRouter): controller=self.resources['share-replicas'], collection={'detail': 'GET'}, member={'action': 'POST'}) + + self.resources['messages'] = messages.create_resource() + mapper.resource("message", "messages", + controller=self.resources['messages']) diff --git a/manila/api/views/messages.py b/manila/api/views/messages.py new file mode 100644 index 00000000..0fd604f7 --- /dev/null +++ b/manila/api/views/messages.py @@ -0,0 +1,68 @@ +# 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 +from manila.message import message_field + + +class ViewBuilder(common.ViewBuilder): + """Model a server API response as a python dictionary.""" + + _collection_name = "messages" + + def index(self, request, messages): + """Show a list of messages.""" + return self._list_view(self.detail, request, messages) + + def detail(self, request, message): + """Detailed view of a single message.""" + message_ref = { + 'id': message.get('id'), + 'project_id': message.get('project_id'), + 'action_id': message.get('action_id'), + 'detail_id': message.get('detail_id'), + 'message_level': message.get('message_level'), + 'created_at': message.get('created_at'), + 'expires_at': message.get('expires_at'), + 'request_id': message.get('request_id'), + 'links': self._get_links(request, message['id']), + 'resource_type': message.get('resource_type'), + 'resource_id': message.get('resource_id'), + 'user_message': "%s: %s" % ( + message_field.translate_action(message.get('action_id')), + message_field.translate_detail(message.get('detail_id'))), + } + + return {'message': message_ref} + + def _list_view(self, func, request, messages, coll_name=_collection_name): + """Provide a view for a list of messages. + + :param func: Function used to format the message data + :param request: API request + :param messages: List of messages in dictionary format + :param coll_name: Name of collection, used to generate the next link + for a pagination query + :returns: message data in dictionary format + """ + messages_list = [func(request, message)['message'] + for message in messages] + messages_links = self._get_collection_links(request, + messages, + coll_name) + messages_dict = dict({"messages": messages_list}) + + if messages_links: + messages_dict['messages_links'] = messages_links + + return messages_dict diff --git a/manila/db/api.py b/manila/db/api.py index 384fa1a2..48c7373a 100644 --- a/manila/db/api.py +++ b/manila/db/api.py @@ -1259,3 +1259,27 @@ def share_group_type_specs_update_or_create(context, type_id, group_specs): """ return IMPL.share_group_type_specs_update_or_create( context, type_id, group_specs) + + +#################### + + +def message_get(context, message_id): + """Return a message with the specified ID.""" + return IMPL.message_get(context, message_id) + + +def message_get_all(context, filters=None, sort_key=None, sort_dir=None): + """Returns all messages with the project of the specified context.""" + return IMPL.message_get_all(context, filters=filters, sort_key=sort_key, + sort_dir=sort_dir) + + +def message_create(context, values): + """Creates a new message with the specified values.""" + return IMPL.message_create(context, values) + + +def message_destroy(context, message_id): + """Deletes message with the specified ID.""" + return IMPL.message_destroy(context, message_id) diff --git a/manila/db/migrations/alembic/versions/238720805ce1_add_messages_table.py b/manila/db/migrations/alembic/versions/238720805ce1_add_messages_table.py new file mode 100644 index 00000000..a1cd221e --- /dev/null +++ b/manila/db/migrations/alembic/versions/238720805ce1_add_messages_table.py @@ -0,0 +1,66 @@ +# 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 messages table + +Revision ID: 238720805ce1 +Revises: 31252d671ae5 +Create Date: 2017-02-02 08:38:55.134095 + +""" + +# revision identifiers, used by Alembic. +revision = '238720805ce1' +down_revision = '31252d671ae5' + +from alembic import op +from oslo_log import log +from sqlalchemy import Column, DateTime +from sqlalchemy import MetaData, String, Table + +LOG = log.getLogger(__name__) + + +def upgrade(): + meta = MetaData() + meta.bind = op.get_bind() + + # New table + messages = Table( + 'messages', + meta, + Column('id', String(36), primary_key=True, nullable=False), + Column('project_id', String(255), nullable=False), + Column('request_id', String(255), nullable=True), + Column('resource_type', String(255)), + Column('resource_id', String(36), nullable=True), + Column('action_id', String(10), nullable=False), + Column('detail_id', String(10), nullable=True), + Column('message_level', String(255), nullable=False), + Column('created_at', DateTime(timezone=False)), + Column('updated_at', DateTime(timezone=False)), + Column('deleted_at', DateTime(timezone=False)), + Column('deleted', String(36)), + Column('expires_at', DateTime(timezone=False)), + mysql_engine='InnoDB', + mysql_charset='utf8' + ) + + messages.create() + + +def downgrade(): + try: + op.drop_table('messages') + except Exception: + LOG.error("messages table not dropped") + raise diff --git a/manila/db/sqlalchemy/api.py b/manila/db/sqlalchemy/api.py index 297cf8ed..808c0c78 100644 --- a/manila/db/sqlalchemy/api.py +++ b/manila/db/sqlalchemy/api.py @@ -4569,3 +4569,66 @@ def share_group_type_specs_update_or_create(context, type_id, specs): spec_ref.save(session=session) return specs + + +############################### + + +@require_context +def message_get(context, message_id): + query = model_query(context, + models.Message, + read_deleted="no", + project_only="yes") + result = query.filter_by(id=message_id).first() + if not result: + raise exception.MessageNotFound(message_id=message_id) + return result + + +@require_context +def message_get_all(context, filters=None, sort_key='created_at', + sort_dir='asc'): + messages = models.Message + query = model_query(context, + messages, + read_deleted="no", + project_only="yes") + + legal_filter_keys = ('request_id', 'resource_type', 'resource_id', + 'action_id', 'detail_id', 'message_level') + + if not filters: + filters = {} + + query = exact_filter(query, messages, filters, legal_filter_keys) + try: + query = apply_sorting(messages, query, sort_key, sort_dir) + except AttributeError: + msg = _("Wrong sorting key provided - '%s'.") % sort_key + raise exception.InvalidInput(reason=msg) + + return query.all() + + +@require_context +def message_create(context, message_values): + values = copy.deepcopy(message_values) + message_ref = models.Message() + if not values.get('id'): + values['id'] = uuidutils.generate_uuid() + message_ref.update(values) + + session = get_session() + with session.begin(): + session.add(message_ref) + + return message_get(context, message_ref['id']) + + +@require_context +def message_destroy(context, message): + session = get_session() + with session.begin(): + (model_query(context, models.Message, session=session). + filter_by(id=message.get('id')).soft_delete()) diff --git a/manila/db/sqlalchemy/models.py b/manila/db/sqlalchemy/models.py index 204bfcb8..4be97680 100644 --- a/manila/db/sqlalchemy/models.py +++ b/manila/db/sqlalchemy/models.py @@ -1178,6 +1178,31 @@ class ShareGroupShareTypeMapping(BASE, ManilaBase): ) +class Message(BASE, ManilaBase): + """Represents a user message. + + User messages show information about API operations to the API end-user. + """ + __tablename__ = 'messages' + id = Column(String(36), primary_key=True, nullable=False) + project_id = Column(String(255), nullable=False) + # Info/Error/Warning. + message_level = Column(String(255), nullable=False) + request_id = Column(String(255), nullable=True) + resource_type = Column(String(255)) + # The uuid of the related resource. + resource_id = Column(String(36), nullable=True) + # Operation specific action ID, this ID is mapped + # to a message in manila/message/message_field.py + action_id = Column(String(10), nullable=False) + # After this time the message may no longer exist. + expires_at = Column(DateTime, nullable=True) + # Message detail ID, this ID is mapped + # to a message in manila/message/message_field.py + detail_id = Column(String(10), nullable=True) + deleted = Column(String(36), default='False') + + def register_models(): """Register Models and create metadata. diff --git a/manila/exception.py b/manila/exception.py index 65abbe5e..f0d1ae8e 100644 --- a/manila/exception.py +++ b/manila/exception.py @@ -206,6 +206,10 @@ class NotFound(ManilaException): safe = True +class MessageNotFound(NotFound): + message = _("Message %(message_id)s could not be found.") + + class Found(ManilaException): message = _("Resource was found.") code = 302 diff --git a/manila/message/__init__.py b/manila/message/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/manila/message/api.py b/manila/message/api.py new file mode 100644 index 00000000..eec194a3 --- /dev/null +++ b/manila/message/api.py @@ -0,0 +1,85 @@ +# 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 user facing messages. +""" +import datetime + +from oslo_config import cfg +from oslo_log import log as logging +from oslo_utils import timeutils +import six + +from manila.db import base +from manila.message import message_field +from manila.message import message_levels + + +messages_opts = [ + cfg.IntOpt('message_ttl', default=2592000, + help='Message minimum life in seconds.'), +] + +CONF = cfg.CONF +CONF.register_opts(messages_opts) + +LOG = logging.getLogger(__name__) + + +class API(base.Base): + """API for handling user messages.""" + + def create(self, context, action, project_id, resource_type=None, + resource_id=None, exception=None, detail=None, + level=message_levels.ERROR): + """Create a message with the specified information.""" + LOG.info("Creating message record for request_id = %s" % + context.request_id) + + # Updates expiry time for message as per message_ttl config. + expires_at = (timeutils.utcnow() + datetime.timedelta( + seconds=CONF.message_ttl)) + detail_id = message_field.translate_detail_id(exception, detail) + + message_record = { + 'project_id': project_id, + 'request_id': context.request_id, + 'resource_type': resource_type, + 'resource_id': resource_id, + 'action_id': action[0], + 'detail_id': detail_id, + 'message_level': level, + 'expires_at': expires_at, + } + try: + self.db.message_create(context, message_record) + except Exception: + LOG.exception("Failed to create message record " + "for request_id %s" % context.request_id) + + def get(self, context, id): + """Return message with the specified message id.""" + return self.db.message_get(context, id) + + def get_all(self, context, search_opts={}, sort_key=None, sort_dir=None): + """Return messages for the given context.""" + LOG.debug("Searching for messages by: %s", + six.text_type(search_opts)) + + messages = self.db.message_get_all( + context, filters=search_opts, sort_key=sort_key, sort_dir=sort_dir) + + return messages + + def delete(self, context, id): + """Delete message with the specified message id.""" + return self.db.message_destroy(context, id) diff --git a/manila/message/message_field.py b/manila/message/message_field.py new file mode 100644 index 00000000..8e3cd8ab --- /dev/null +++ b/manila/message/message_field.py @@ -0,0 +1,64 @@ +# 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.i18n import _ + + +class Resource(object): + + SHARE = 'SHARE' + + +class Action(object): + + ALLOCATE_HOST = ('001', _('allocate host')) + + ALL = (ALLOCATE_HOST,) + + +class Detail(object): + + UNKNOWN_ERROR = ('001', _('An unknown error occurred.')) + NO_VALID_HOST = ('002', _("No storage could be allocated for this share " + "request. Trying again with a different size " + "or share type may succeed.")) + + ALL = (UNKNOWN_ERROR, + NO_VALID_HOST) + + # Exception and detail mappings + EXCEPTION_DETAIL_MAPPINGS = { + NO_VALID_HOST: ['NoValidHost'], + } + + +def translate_action(action_id): + action_message = next((action[1] for action in Action.ALL + if action[0] == action_id), None) + return action_message or 'unknown action' + + +def translate_detail(detail_id): + detail_message = next((action[1] for action in Detail.ALL + if action[0] == detail_id), None) + return detail_message or Detail.UNKNOWN_ERROR[1] + + +def translate_detail_id(exception, detail): + if exception is not None and isinstance(exception, Exception): + for key, value in Detail.EXCEPTION_DETAIL_MAPPINGS.items(): + if exception.__class__.__name__ in value: + return key[0] + if (detail in Detail.ALL and + detail is not Detail.EXCEPTION_DETAIL_MAPPINGS): + return detail[0] + return Detail.UNKNOWN_ERROR[0] diff --git a/manila/message/message_levels.py b/manila/message/message_levels.py new file mode 100644 index 00000000..9ef93c5f --- /dev/null +++ b/manila/message/message_levels.py @@ -0,0 +1,15 @@ +# 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. + +"""Message level constants.""" + +ERROR = 'ERROR' diff --git a/manila/opts.py b/manila/opts.py index d9095cd2..04326d98 100644 --- a/manila/opts.py +++ b/manila/opts.py @@ -33,6 +33,7 @@ import manila.coordination import manila.db.api import manila.db.base import manila.exception +import manila.message.api import manila.network import manila.network.linux.interface import manila.network.neutron.api @@ -102,6 +103,7 @@ _global_opt_lists = [ manila.db.api.db_opts, [manila.db.base.db_driver_opt], manila.exception.exc_log_opts, + manila.message.api.messages_opts, manila.network.linux.interface.OPTS, manila.network.network_opts, manila.network.neutron.neutron_network_plugin. diff --git a/manila/scheduler/manager.py b/manila/scheduler/manager.py index 78853db6..0c4f6c6c 100644 --- a/manila/scheduler/manager.py +++ b/manila/scheduler/manager.py @@ -30,6 +30,8 @@ from manila import context from manila import db from manila import exception from manila import manager +from manila.message import api as message_api +from manila.message import message_field from manila import quota from manila import rpc from manila.share import rpcapi as share_rpcapi @@ -77,6 +79,7 @@ class SchedulerManager(manager.Manager): scheduler_driver = MAPPING[scheduler_driver] self.driver = importutils.import_object(scheduler_driver) + self.message_api = message_api.API() super(self.__class__, self).__init__(*args, **kwargs) def init_host(self): @@ -106,16 +109,15 @@ class SchedulerManager(manager.Manager): self.driver.schedule_create_share(context, request_spec, filter_properties) except exception.NoValidHost as ex: - self._set_share_state_and_notify('create_share', - {'status': - constants.STATUS_ERROR}, - context, ex, request_spec) + self._set_share_state_and_notify( + 'create_share', {'status': constants.STATUS_ERROR}, + context, ex, request_spec, + message_field.Action.ALLOCATE_HOST) except Exception as ex: with excutils.save_and_reraise_exception(): - self._set_share_state_and_notify('create_share', - {'status': - constants.STATUS_ERROR}, - context, ex, request_spec) + self._set_share_state_and_notify( + 'create_share', {'status': constants.STATUS_ERROR}, + context, ex, request_spec) def get_pools(self, context, filters=None): """Get active pools from the scheduler's cache.""" @@ -188,7 +190,7 @@ class SchedulerManager(manager.Manager): _migrate_share_set_error(self, context, ex, request_spec) def _set_share_state_and_notify(self, method, state, context, ex, - request_spec): + request_spec, action=None): LOG.error("Failed to schedule %(method)s: %(ex)s", {"method": method, "ex": ex}) @@ -200,6 +202,12 @@ class SchedulerManager(manager.Manager): if share_id: db.share_update(context, share_id, state) + if action: + self.message_api.create( + context, action, context.project_id, + resource_type=message_field.Resource.SHARE, + resource_id=share_id, exception=ex) + payload = dict(request_spec=request_spec, share_properties=properties, share_id=share_id, diff --git a/manila/tests/api/v2/stubs.py b/manila/tests/api/v2/stubs.py new file mode 100644 index 00000000..b6a23b1f --- /dev/null +++ b/manila/tests/api/v2/stubs.py @@ -0,0 +1,47 @@ +# 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. + +import datetime +import iso8601 + +from manila.message import message_field +from manila.message import message_levels +from manila.tests.api import fakes + + +FAKE_UUID = fakes.FAKE_UUID + + +def stub_message(id, **kwargs): + message = { + 'id': id, + 'project_id': 'fake_project', + 'action_id': message_field.Action.ALLOCATE_HOST[0], + 'message_level': message_levels.ERROR, + 'request_id': FAKE_UUID, + 'resource_type': message_field.Resource.SHARE, + 'resource_id': 'fake_uuid', + 'updated_at': datetime.datetime(1900, 1, 1, 1, 1, 1, + tzinfo=iso8601.iso8601.Utc()), + 'created_at': datetime.datetime(1900, 1, 1, 1, 1, 1, + tzinfo=iso8601.iso8601.Utc()), + 'expires_at': datetime.datetime(1900, 1, 1, 1, 1, 1, + tzinfo=iso8601.iso8601.Utc()), + 'detail_id': message_field.Detail.NO_VALID_HOST[0], + } + + message.update(kwargs) + return message + + +def stub_message_get(self, context, message_id): + return stub_message(message_id) diff --git a/manila/tests/api/v2/test_messages.py b/manila/tests/api/v2/test_messages.py new file mode 100644 index 00000000..09b50554 --- /dev/null +++ b/manila/tests/api/v2/test_messages.py @@ -0,0 +1,186 @@ +# 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. + +import mock +from oslo_config import cfg +import webob + +from manila.api.v2 import messages +from manila import context +from manila import exception +from manila.message import api as message_api +from manila.message import message_field +from manila import policy +from manila import test +from manila.tests.api import fakes +from manila.tests.api.v2 import stubs + +CONF = cfg.CONF + + +class MessageApiTest(test.TestCase): + def setUp(self): + super(MessageApiTest, self).setUp() + self.controller = messages.MessagesController() + + self.maxDiff = None + self.ctxt = context.RequestContext('admin', 'fake', True) + self.mock_object(policy, 'check_policy', + mock.Mock(return_value=True)) + + def _expected_message_from_controller(self, id): + message = stubs.stub_message(id) + links = [ + {'href': 'http://localhost/v2/fake/messages/%s' % id, + 'rel': 'self'}, + {'href': 'http://localhost/fake/messages/%s' % id, + 'rel': 'bookmark'}, + ] + return { + 'message': { + 'id': message.get('id'), + 'project_id': message.get('project_id'), + 'user_message': "%s: %s" % ( + message_field.translate_action(message.get('action_id')), + message_field.translate_detail(message.get('detail_id'))), + 'request_id': message.get('request_id'), + 'action_id': message.get('action_id'), + 'detail_id': message.get('detail_id'), + 'created_at': message.get('created_at'), + 'message_level': message.get('message_level'), + 'expires_at': message.get('expires_at'), + 'links': links, + 'resource_type': message.get('resource_type'), + 'resource_id': message.get('resource_id'), + } + } + + def test_show(self): + self.mock_object(message_api.API, 'get', stubs.stub_message_get) + + req = fakes.HTTPRequest.blank( + '/messages/%s' % fakes.FAKE_UUID, + version=messages.MESSAGES_BASE_MICRO_VERSION, + base_url='http://localhost/v2') + req.environ['manila.context'] = self.ctxt + + res_dict = self.controller.show(req, fakes.FAKE_UUID) + + ex = self._expected_message_from_controller(fakes.FAKE_UUID) + self.assertEqual(ex, res_dict) + + def test_show_with_resource(self): + resource_type = "FAKE_RESOURCE" + resource_id = "b1872cb2-4c5f-4072-9828-8a51b02926a3" + fake_message = stubs.stub_message(fakes.FAKE_UUID, + resource_type=resource_type, + resource_id=resource_id) + mock_get = mock.Mock(return_value=fake_message) + self.mock_object(message_api.API, 'get', mock_get) + + req = fakes.HTTPRequest.blank( + '/messages/%s' % fakes.FAKE_UUID, + version=messages.MESSAGES_BASE_MICRO_VERSION, + base_url='http://localhost/v2') + req.environ['manila.context'] = self.ctxt + + res_dict = self.controller.show(req, fakes.FAKE_UUID) + + self.assertEqual(resource_type, + res_dict['message']['resource_type']) + self.assertEqual(resource_id, + res_dict['message']['resource_id']) + + def test_show_not_found(self): + fake_not_found = exception.MessageNotFound(message_id=fakes.FAKE_UUID) + self.mock_object(message_api.API, 'get', + mock.Mock(side_effect=fake_not_found)) + + req = fakes.HTTPRequest.blank( + '/messages/%s' % fakes.FAKE_UUID, + version=messages.MESSAGES_BASE_MICRO_VERSION, + base_url='http://localhost/v2') + req.environ['manila.context'] = self.ctxt + + self.assertRaises(webob.exc.HTTPNotFound, self.controller.show, + req, fakes.FAKE_UUID) + + def test_show_pre_microversion(self): + self.mock_object(message_api.API, 'get', stubs.stub_message_get) + + req = fakes.HTTPRequest.blank('/messages/%s' % fakes.FAKE_UUID, + version='2.35', + base_url='http://localhost/v2') + req.environ['manila.context'] = self.ctxt + + self.assertRaises(exception.VersionNotFoundForAPIMethod, + self.controller.show, req, fakes.FAKE_UUID) + + def test_delete(self): + self.mock_object(message_api.API, 'get', stubs.stub_message_get) + self.mock_object(message_api.API, 'delete') + + req = fakes.HTTPRequest.blank( + '/messages/%s' % fakes.FAKE_UUID, + version=messages.MESSAGES_BASE_MICRO_VERSION) + req.environ['manila.context'] = self.ctxt + + resp = self.controller.delete(req, fakes.FAKE_UUID) + + self.assertEqual(204, resp.status_int) + self.assertTrue(message_api.API.delete.called) + + def test_delete_not_found(self): + fake_not_found = exception.MessageNotFound(message_id=fakes.FAKE_UUID) + self.mock_object(message_api.API, 'get', + mock.Mock(side_effect=fake_not_found)) + + req = fakes.HTTPRequest.blank( + '/messages/%s' % fakes.FAKE_UUID, + version=messages.MESSAGES_BASE_MICRO_VERSION) + + self.assertRaises(webob.exc.HTTPNotFound, self.controller.delete, + req, fakes.FAKE_UUID) + + def test_index(self): + msg1 = stubs.stub_message(fakes.get_fake_uuid()) + msg2 = stubs.stub_message(fakes.get_fake_uuid()) + self.mock_object(message_api.API, 'get_all', mock.Mock( + return_value=[msg1, msg2])) + req = fakes.HTTPRequest.blank( + '/messages', + version=messages.MESSAGES_BASE_MICRO_VERSION, + base_url='http://localhost/v2') + req.environ['manila.context'] = self.ctxt + + res_dict = self.controller.index(req) + + ex1 = self._expected_message_from_controller(msg1['id'])['message'] + ex2 = self._expected_message_from_controller(msg2['id'])['message'] + expected = {'messages': [ex1, ex2]} + self.assertDictMatch(expected, res_dict) + + def test_index_with_limit_and_offset(self): + msg1 = stubs.stub_message(fakes.get_fake_uuid()) + msg2 = stubs.stub_message(fakes.get_fake_uuid()) + self.mock_object(message_api.API, 'get_all', mock.Mock( + return_value=[msg1, msg2])) + req = fakes.HTTPRequest.blank( + '/messages?limit=1&offset=1', + version=messages.MESSAGES_BASE_MICRO_VERSION, + base_url='http://localhost/v2') + req.environ['manila.context'] = self.ctxt + + res_dict = self.controller.index(req) + + ex2 = self._expected_message_from_controller(msg2['id'])['message'] + self.assertEqual([ex2], res_dict['messages']) diff --git a/manila/tests/db/migrations/alembic/migrations_data_checks.py b/manila/tests/db/migrations/alembic/migrations_data_checks.py index 422be085..5e688fdd 100644 --- a/manila/tests/db/migrations/alembic/migrations_data_checks.py +++ b/manila/tests/db/migrations/alembic/migrations_data_checks.py @@ -2364,3 +2364,35 @@ class SquashSGSnapshotMembersAndSSIModelsChecks(BaseMigrationChecks): db_result = engine.execute(ssi_table.select().where( ssi_table.c.id == self.share_group_snapshot_member_id)) self.test_case.assertEqual(0, db_result.rowcount) + + +@map_to_migration('238720805ce1') +class MessagesTableChecks(BaseMigrationChecks): + new_table_name = 'messages' + + def setup_upgrade_data(self, engine): + pass + + def check_upgrade(self, engine, data): + message_data = { + 'id': uuidutils.generate_uuid(), + 'project_id': 'x' * 255, + 'request_id': 'x' * 255, + 'resource_type': 'x' * 255, + 'resource_id': 'y' * 36, + 'action_id': 'y' * 10, + 'detail_id': 'y' * 10, + 'message_level': 'x' * 255, + 'created_at': datetime.datetime(2017, 7, 10, 18, 5, 58), + 'updated_at': None, + 'deleted_at': None, + 'deleted': 0, + 'expires_at': datetime.datetime(2017, 7, 11, 18, 5, 58), + } + + new_table = utils.load_table(self.new_table_name, engine) + engine.execute(new_table.insert(message_data)) + + def check_downgrade(self, engine): + self.test_case.assertRaises(sa_exc.NoSuchTableError, utils.load_table, + 'messages', engine) diff --git a/manila/tests/db/sqlalchemy/test_api.py b/manila/tests/db/sqlalchemy/test_api.py index 6d073ea3..396c50b3 100644 --- a/manila/tests/db/sqlalchemy/test_api.py +++ b/manila/tests/db/sqlalchemy/test_api.py @@ -2738,3 +2738,89 @@ class ShareTypeAPITestCase(test.TestCase): result = db_api.share_type_get_by_name_or_id(self.ctxt, fake_id) self.assertIsNone(result) + + +class MessagesDatabaseAPITestCase(test.TestCase): + + def setUp(self): + super(MessagesDatabaseAPITestCase, self).setUp() + self.user_id = uuidutils.generate_uuid() + self.project_id = uuidutils.generate_uuid() + self.ctxt = context.RequestContext( + user_id=self.user_id, project_id=self.project_id, is_admin=False) + + def test_message_create(self): + result = db_utils.create_message(project_id=self.project_id, + action_id='001') + + self.assertIsNotNone(result['id']) + + def test_message_delete(self): + result = db_utils.create_message(project_id=self.project_id, + action_id='001') + + db_api.message_destroy(self.ctxt, result) + + self.assertRaises(exception.NotFound, db_api.message_get, + self.ctxt, result['id']) + + def test_message_get(self): + message = db_utils.create_message(project_id=self.project_id, + action_id='001') + + result = db_api.message_get(self.ctxt, message['id']) + + self.assertEqual(message['id'], result['id']) + self.assertEqual(message['action_id'], result['action_id']) + self.assertEqual(message['detail_id'], result['detail_id']) + self.assertEqual(message['project_id'], result['project_id']) + self.assertEqual(message['message_level'], result['message_level']) + + def test_message_get_not_found(self): + self.assertRaises(exception.MessageNotFound, db_api.message_get, + self.ctxt, 'fake_id') + + def test_message_get_different_project(self): + message = db_utils.create_message(project_id='another-project', + action_id='001') + + self.assertRaises(exception.MessageNotFound, db_api.message_get, + self.ctxt, message['id']) + + def test_message_get_all(self): + db_utils.create_message(project_id=self.project_id, action_id='001') + db_utils.create_message(project_id=self.project_id, action_id='001') + db_utils.create_message(project_id='another-project', action_id='001') + + result = db_api.message_get_all(self.ctxt) + + self.assertEqual(2, len(result)) + + def test_message_get_all_as_admin(self): + db_utils.create_message(project_id=self.project_id, action_id='001') + db_utils.create_message(project_id=self.project_id, action_id='001') + db_utils.create_message(project_id='another-project', action_id='001') + + result = db_api.message_get_all(self.ctxt.elevated()) + + self.assertEqual(3, len(result)) + + def test_message_get_all_with_filter(self): + for i in ['001', '002', '002']: + db_utils.create_message(project_id=self.project_id, action_id=i) + + result = db_api.message_get_all(self.ctxt, + filters={'action_id': '002'}) + + self.assertEqual(2, len(result)) + + def test_message_get_all_sorted(self): + ids = [] + for i in ['001', '002', '003']: + msg = db_utils.create_message(project_id=self.project_id, + action_id=i) + ids.append(msg.id) + + result = db_api.message_get_all(self.ctxt, sort_key='action_id') + result_ids = [r.id for r in result] + self.assertEqual(result_ids, ids) diff --git a/manila/tests/db_utils.py b/manila/tests/db_utils.py index f33cf63a..75f8ebbd 100644 --- a/manila/tests/db_utils.py +++ b/manila/tests/db_utils.py @@ -18,6 +18,7 @@ import copy from manila.common import constants from manila import context from manila import db +from manila.message import message_levels def _create_db_row(method, default_values, custom_values): @@ -264,3 +265,12 @@ def create_security_service(**kwargs): share_network_id, service_ref['id']) return service_ref + + +def create_message(**kwargs): + message_dict = { + 'action': 'fake_Action', + 'project_id': 'fake-project-id', + 'message_level': message_levels.ERROR, + } + return _create_db_row(db.message_create, message_dict, kwargs) diff --git a/manila/tests/message/__init__.py b/manila/tests/message/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/manila/tests/message/test_api.py b/manila/tests/message/test_api.py new file mode 100644 index 00000000..065e4c7f --- /dev/null +++ b/manila/tests/message/test_api.py @@ -0,0 +1,92 @@ +# 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. +import datetime + +import mock +from oslo_config import cfg +from oslo_utils import timeutils + +from manila import context +from manila.message import api as message_api +from manila.message.message_field import Action as MsgAction +from manila.message.message_field import Detail as MsgDetail +from manila.message import message_levels +from manila import test + +CONF = cfg.CONF + + +class MessageApiTest(test.TestCase): + def setUp(self): + super(MessageApiTest, self).setUp() + self.message_api = message_api.API() + self.mock_object(self.message_api, 'db') + self.ctxt = context.RequestContext('admin', 'fakeproject', True) + self.ctxt.request_id = 'fakerequestid' + + def test_create(self): + CONF.set_override('message_ttl', 300) + timeutils.set_time_override() + self.addCleanup(timeutils.clear_time_override) + expected_expires_at = timeutils.utcnow() + datetime.timedelta( + seconds=300) + expected_message_record = { + 'project_id': 'fakeproject', + 'request_id': 'fakerequestid', + 'resource_type': 'fake_resource_type', + 'resource_id': None, + 'action_id': MsgAction.ALLOCATE_HOST[0], + 'detail_id': MsgDetail.NO_VALID_HOST[0], + 'message_level': message_levels.ERROR, + 'expires_at': expected_expires_at, + } + + self.message_api.create(self.ctxt, + MsgAction.ALLOCATE_HOST, + "fakeproject", + detail=MsgDetail.NO_VALID_HOST, + resource_type="fake_resource_type") + + self.message_api.db.message_create.assert_called_once_with( + self.ctxt, expected_message_record) + + def test_create_swallows_exception(self): + self.mock_object(self.message_api.db, 'message_create', + mock.Mock(side_effect=Exception())) + exception_log = self.mock_object(message_api.LOG, 'exception') + self.message_api.create(self.ctxt, + MsgAction.ALLOCATE_HOST, + 'fakeproject', + 'fake_resource') + + self.message_api.db.message_create.assert_called_once_with( + self.ctxt, mock.ANY) + exception_log.assert_called_once_with( + 'Failed to create message record for request_id fakerequestid') + + def test_get(self): + self.message_api.get(self.ctxt, 'fake_id') + + self.message_api.db.message_get.assert_called_once_with(self.ctxt, + 'fake_id') + + def test_get_all(self): + self.message_api.get_all(self.ctxt) + + self.message_api.db.message_get_all.assert_called_once_with( + self.ctxt, filters={}, sort_dir=None, sort_key=None) + + def test_delete(self): + self.message_api.delete(self.ctxt, 'fake_id') + + self.message_api.db.message_destroy.assert_called_once_with( + self.ctxt, 'fake_id') diff --git a/manila/tests/message/test_message_field.py b/manila/tests/message/test_message_field.py new file mode 100644 index 00000000..8281d567 --- /dev/null +++ b/manila/tests/message/test_message_field.py @@ -0,0 +1,62 @@ +# 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. +import ddt +from oslo_config import cfg + +from manila import exception +from manila.message import message_field +from manila import test + +CONF = cfg.CONF + + +@ddt.ddt +class MessageFieldTest(test.TestCase): + + @ddt.data(message_field.Action, message_field.Detail) + def test_unique_ids(self, cls): + """Assert that no action or detail id is duplicated.""" + ids = [name[0] for name in cls.ALL] + self.assertEqual(len(ids), len(set(ids))) + + @ddt.data({'id': '001', 'content': 'allocate host'}, + {'id': 'invalid', 'content': None}) + @ddt.unpack + def test_translate_action(self, id, content): + result = message_field.translate_action(id) + if content is None: + content = 'unknown action' + self.assertEqual(content, result) + + @ddt.data({'id': '001', + 'content': 'An unknown error occurred.'}, + {'id': '002', + 'content': 'No storage could be allocated for this share ' + 'request. Trying again with a different size or ' + 'share type may succeed.'}, + {'id': 'invalid', 'content': None}) + @ddt.unpack + def test_translate_detail(self, id, content): + result = message_field.translate_detail(id) + if content is None: + content = 'An unknown error occurred.' + self.assertEqual(content, result) + + @ddt.data({'exception': exception.NoValidHost(reason='fake reason'), + 'detail': '', + 'expected': '002'}, + {'exception': '', 'detail': message_field.Detail.NO_VALID_HOST, + 'expected': '002'}) + @ddt.unpack + def test_translate_detail_id(self, exception, detail, expected): + result = message_field.translate_detail_id(exception, detail) + self.assertEqual(expected, result) diff --git a/manila/tests/policy.json b/manila/tests/policy.json index 167cc2ed..68eba1e5 100644 --- a/manila/tests/policy.json +++ b/manila/tests/policy.json @@ -130,5 +130,9 @@ "share_group_types_spec:update": "rule:admin_api", "share_group_types_spec:show": "rule:admin_api", "share_group_types_spec:index": "rule:admin_api", - "share_group_types_spec:delete": "rule:admin_api" + "share_group_types_spec:delete": "rule:admin_api", + + "message:delete": "rule:default", + "message:get": "rule:default", + "message:get_all": "rule:default" } diff --git a/manila/tests/scheduler/test_manager.py b/manila/tests/scheduler/test_manager.py index 7734f3a6..74d77d6b 100644 --- a/manila/tests/scheduler/test_manager.py +++ b/manila/tests/scheduler/test_manager.py @@ -31,6 +31,7 @@ from manila.common import constants from manila import context from manila import db from manila import exception +from manila.message import message_field from manila import quota from manila.scheduler.drivers import base from manila.scheduler.drivers import filter @@ -136,7 +137,9 @@ class SchedulerManagerTestCase(test.TestCase): assert_called_once_with(service_name, host, capabilities)) @mock.patch.object(db, 'share_update', mock.Mock()) - def test_create_share_exception_puts_share_in_error_state(self): + @mock.patch('manila.message.api.API.create') + def test_create_share_exception_puts_share_in_error_state( + self, _mock_message_create): """Test NoValidHost exception for create_share. Puts the share in 'error' state and eats the exception. @@ -144,9 +147,10 @@ class SchedulerManagerTestCase(test.TestCase): fake_share_id = 1 request_spec = {'share_id': fake_share_id} + ex = exception.NoValidHost(reason='') with mock.patch.object( self.manager.driver, 'schedule_create_share', - mock.Mock(side_effect=self.raise_no_valid_host)): + mock.Mock(side_effect=ex)): self.mock_object(manager.LOG, 'error') self.manager.create_share_instance( @@ -158,6 +162,12 @@ class SchedulerManagerTestCase(test.TestCase): assert_called_once_with(self.context, request_spec, {})) manager.LOG.error.assert_called_once_with(mock.ANY, mock.ANY) + _mock_message_create.assert_called_once_with( + self.context, + message_field.Action.ALLOCATE_HOST, + self.context.project_id, resource_type='SHARE', + exception=ex, resource_id=fake_share_id) + @mock.patch.object(db, 'share_update', mock.Mock()) def test_create_share_other_exception_puts_share_in_error_state(self): """Test any exception except NoValidHost for create_share. diff --git a/manila_tempest_tests/config.py b/manila_tempest_tests/config.py index b48ac9f4..e13d334c 100644 --- a/manila_tempest_tests/config.py +++ b/manila_tempest_tests/config.py @@ -30,7 +30,7 @@ ShareGroup = [ help="The minimum api microversion is configured to be the " "value of the minimum microversion supported by Manila."), cfg.StrOpt("max_api_microversion", - default="2.36", + default="2.37", help="The maximum api microversion is configured to be the " "value of the latest microversion supported by Manila."), cfg.StrOpt("region", diff --git a/manila_tempest_tests/services/share/v2/json/shares_client.py b/manila_tempest_tests/services/share/v2/json/shares_client.py index 353c8cc7..510b2f74 100644 --- a/manila_tempest_tests/services/share/v2/json/shares_client.py +++ b/manila_tempest_tests/services/share/v2/json/shares_client.py @@ -186,6 +186,9 @@ class SharesV2Client(shares_client.SharesClient): elif "replica_id" in kwargs: return self._is_resource_deleted( self.get_share_replica, kwargs.get("replica_id")) + elif "message_id" in kwargs: + return self._is_resource_deleted( + self.get_message, kwargs.get("message_id")) else: return super(SharesV2Client, self).is_resource_deleted( *args, **kwargs) @@ -1673,3 +1676,44 @@ class SharesV2Client(shares_client.SharesClient): "snapshots/%s/export-locations" % snapshot_id, version=version) self.expected_success(200, resp.status) return self._parse_resp(body) + +############### + + def get_message(self, message_id, version=LATEST_MICROVERSION): + """Show details for a single message.""" + url = 'messages/%s' % message_id + resp, body = self.get(url, version=version) + self.expected_success(200, resp.status) + return self._parse_resp(body) + + def list_messages(self, params=None, version=LATEST_MICROVERSION): + """List all messages.""" + url = 'messages' + url += '?%s' % urlparse.urlencode(params) if params else '' + resp, body = self.get(url, version=version) + self.expected_success(200, resp.status) + return self._parse_resp(body) + + def delete_message(self, message_id, version=LATEST_MICROVERSION): + """Delete a single message.""" + url = 'messages/%s' % message_id + resp, body = self.delete(url, version=version) + self.expected_success(204, resp.status) + return self._parse_resp(body) + + def wait_for_message(self, resource_id): + """Waits until a message for a resource with given id exists""" + start = int(time.time()) + message = None + + while not message: + time.sleep(self.build_interval) + for msg in self.list_messages(): + if msg['resource_id'] == resource_id: + return msg + + if int(time.time()) - start >= self.build_timeout: + message = ('No message for resource with id %s was created in' + ' the required time (%s s).' % + (resource_id, self.build_timeout)) + raise exceptions.TimeoutException(message) diff --git a/manila_tempest_tests/tests/api/admin/test_user_messages.py b/manila_tempest_tests/tests/api/admin/test_user_messages.py new file mode 100644 index 00000000..1d23487e --- /dev/null +++ b/manila_tempest_tests/tests/api/admin/test_user_messages.py @@ -0,0 +1,103 @@ +# 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_utils import timeutils +from oslo_utils import uuidutils +from tempest import config +from tempest import test + +from manila_tempest_tests.tests.api import base + +CONF = config.CONF + +MICROVERSION = '2.37' +MESSAGE_KEYS = ( + 'created_at', + 'action_id', + 'detail_id', + 'expires_at', + 'id', + 'message_level', + 'request_id', + 'resource_type', + 'resource_id', + 'user_message', + 'project_id', + 'links', +) + + +@base.skip_if_microversion_lt(MICROVERSION) +class UserMessageTest(base.BaseSharesAdminTest): + + def setUp(self): + super(UserMessageTest, self).setUp() + self.message = self.create_user_message() + + @test.attr(type=[base.TAG_POSITIVE, base.TAG_API]) + def test_list_messages(self): + body = self.shares_v2_client.list_messages() + self.assertIsInstance(body, list) + self.assertTrue(self.message['id'], [x['id'] for x in body]) + message = body[0] + self.assertEqual(set(MESSAGE_KEYS), set(message.keys())) + + @test.attr(type=[base.TAG_POSITIVE, base.TAG_API]) + def test_list_messages_sorted_and_paginated(self): + self.create_user_message() + self.create_user_message() + params = {'sort_key': 'resource_id', 'sort_dir': 'asc', 'limit': 2} + body = self.shares_v2_client.list_messages(params=params) + # tempest/lib/common/rest_client.py's _parse_resp checks + # for number of keys in response's dict, if there is only single + # key, it returns directly this key, otherwise it returns + # parsed body. If limit param is used, then API returns + # multiple keys in reponse ('messages' and 'message_links') + messages = body['messages'] + self.assertIsInstance(messages, list) + ids = [x['resource_id'] for x in messages] + self.assertEqual(2, len(ids)) + self.assertEqual(ids, sorted(ids)) + + @test.attr(type=[base.TAG_POSITIVE, base.TAG_API]) + def test_list_messages_filtered(self): + self.create_user_message() + params = {'resource_id': self.message['resource_id']} + body = self.shares_v2_client.list_messages(params=params) + self.assertIsInstance(body, list) + ids = [x['id'] for x in body] + self.assertEqual([self.message['id']], ids) + + @test.attr(type=[base.TAG_POSITIVE, base.TAG_API]) + def test_show_message(self): + self.addCleanup(self.shares_v2_client.delete_message, + self.message['id']) + + message = self.shares_v2_client.get_message(self.message['id']) + + self.assertEqual(set(MESSAGE_KEYS), set(message.keys())) + self.assertTrue(uuidutils.is_uuid_like(message['id'])) + self.assertEqual('001', message['action_id']) + self.assertEqual('002', message['detail_id']) + self.assertEqual('SHARE', message['resource_type']) + self.assertTrue(uuidutils.is_uuid_like(message['resource_id'])) + self.assertEqual('ERROR', message['message_level']) + created_at = timeutils.parse_strtime(message['created_at']) + expires_at = timeutils.parse_strtime(message['expires_at']) + self.assertGreater(expires_at, created_at) + self.assertEqual(set(MESSAGE_KEYS), set(message.keys())) + + @test.attr(type=[base.TAG_POSITIVE, base.TAG_API]) + def test_delete_message(self): + self.shares_v2_client.delete_message(self.message['id']) + self.shares_v2_client.wait_for_resource_deletion( + message_id=self.message['id']) diff --git a/manila_tempest_tests/tests/api/admin/test_user_messages_negative.py b/manila_tempest_tests/tests/api/admin/test_user_messages_negative.py new file mode 100644 index 00000000..47eed3b5 --- /dev/null +++ b/manila_tempest_tests/tests/api/admin/test_user_messages_negative.py @@ -0,0 +1,58 @@ +# 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_utils import uuidutils +import six +from tempest import config +from tempest.lib import exceptions as lib_exc +from tempest import test + +from manila_tempest_tests.tests.api import base + +CONF = config.CONF + +MICROVERSION = '2.37' + + +@base.skip_if_microversion_lt(MICROVERSION) +class UserMessageNegativeTest(base.BaseSharesAdminTest): + + def setUp(self): + super(UserMessageNegativeTest, self).setUp() + self.message = self.create_user_message() + + @test.attr(type=[base.TAG_NEGATIVE, base.TAG_API]) + def test_show_message_of_other_tenants(self): + isolated_client = self.get_client_with_isolated_creds( + type_of_creds='alt', client_version='2') + self.assertRaises(lib_exc.NotFound, + isolated_client.get_message, + self.message['id']) + + @test.attr(type=[base.TAG_NEGATIVE, base.TAG_API]) + def test_show_nonexistent_message(self): + self.assertRaises(lib_exc.NotFound, + self.shares_v2_client.get_message, + six.text_type(uuidutils.generate_uuid())) + + @test.attr(type=[base.TAG_NEGATIVE, base.TAG_API]) + def test_delete_message_of_other_tenants(self): + isolated_client = self.get_client_with_isolated_creds( + type_of_creds='alt', client_version='2') + self.assertRaises(lib_exc.NotFound, + isolated_client.delete_message, + self.message['id']) + + @test.attr(type=[base.TAG_NEGATIVE, base.TAG_API]) + def test_delete_nonexistent_message(self): + self.assertRaises(lib_exc.NotFound, + self.shares_v2_client.delete_message, + six.text_type(uuidutils.generate_uuid())) diff --git a/manila_tempest_tests/tests/api/base.py b/manila_tempest_tests/tests/api/base.py index 3df71537..764a8529 100644 --- a/manila_tempest_tests/tests/api/base.py +++ b/manila_tempest_tests/tests/api/base.py @@ -998,6 +998,25 @@ class BaseSharesTest(test.BaseTestCase): "d2value": d2value }) + def create_user_message(self): + """Trigger a 'no valid host' situation to generate a message.""" + extra_specs = { + 'vendor_name': 'foobar', + 'driver_handles_share_servers': CONF.share.multitenancy_enabled, + } + share_type_name = data_utils.rand_name("share-type") + + bogus_type = self.create_share_type( + name=share_type_name, + extra_specs=extra_specs)['share_type'] + + params = {'share_type_id': bogus_type['id'], + 'share_network_id': self.shares_v2_client.share_network_id} + share = self.shares_v2_client.create_share(**params) + self.addCleanup(self.shares_v2_client.delete_share, share['id']) + self.shares_v2_client.wait_for_share_status(share['id'], "error") + return self.shares_v2_client.wait_for_message(share['id']) + class BaseSharesAltTest(BaseSharesTest): """Base test case class for all Shares Alt API tests.""" diff --git a/releasenotes/notes/user-messages-api-589ee7d68ccba70c.yaml b/releasenotes/notes/user-messages-api-589ee7d68ccba70c.yaml new file mode 100644 index 00000000..5316470e --- /dev/null +++ b/releasenotes/notes/user-messages-api-589ee7d68ccba70c.yaml @@ -0,0 +1,7 @@ +--- +features: + - Added new user messages API - GET /messages, GET /messages/ + and DELETE /messages/. + - Added sorting, filtering and pagination to the user messages listing. + - Added 'message_ttl' configuration option which can be used for + configuring message expiration time.