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/<message_id>
DELETE /messages/<message_id>

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 <jprovazn@redhat.com>
Change-Id: Ia0cc524e0bfb2ca5e495e575e17e9911c746690b
This commit is contained in:
Alex Meade 2016-05-06 09:33:09 -04:00 committed by Jan Provaznik
parent dadf6efea2
commit dd630c3929
40 changed files with 1660 additions and 15 deletions

View File

@ -25,6 +25,7 @@ Shared File Systems API
.. include:: availability-zones.inc .. include:: availability-zones.inc
.. include:: os-share-manage.inc .. include:: os-share-manage.inc
.. include:: quota-sets.inc .. include:: quota-sets.inc
.. include:: user-messages.inc
====================================== ======================================
Shared File Systems API (EXPERIMENTAL) Shared File Systems API (EXPERIMENTAL)

View File

@ -50,6 +50,12 @@ export_location_id_path:
in: path in: path
required: false required: false
type: string type: string
message_id:
description: |
The UUID of the message.
in: path
required: false
type: string
security_service_id_path: security_service_id_path:
description: | description: |
The UUID of the security service. The UUID of the security service.
@ -121,6 +127,12 @@ tenant_id_path:
type: string type: string
# variables in query # 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: all_tenants:
description: | description: |
(Admin only). Defines whether to list shares for (Admin only). Defines whether to list shares for
@ -206,6 +218,12 @@ consistency_group_id_5:
in: query in: query
required: false required: false
type: string type: string
detail_id:
in: query
required: false
type: string
description: >
The ID of the message detail.
export_location_id_query: export_location_id_query:
description: | description: |
The export location UUID that can be used to filter shares or The export location UUID that can be used to filter shares or
@ -275,6 +293,12 @@ media_types:
in: query in: query
required: false required: false
type: object type: object
message_level:
in: query
required: false
type: string
description: >
The message level.
metadata_1: metadata_1:
description: | description: |
One or more metadata key-value pairs, as a One or more metadata key-value pairs, as a
@ -316,6 +340,30 @@ project_id_6:
in: query in: query
required: false required: false
type: string 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: service_binary_query:
description: | description: |
The service binary name. Default is the base name The service binary name. Default is the base name
@ -399,6 +447,15 @@ sort_key:
in: query in: query
required: false required: false
type: string 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: state_2:
description: | description: |
The current state of the service. A valid value The current state of the service. A valid value
@ -577,6 +634,12 @@ access_type:
in: body in: body
required: true required: true
type: string type: string
action_id_body:
in: body
required: true
type: string
description: >
The ID of the action during which the message was created.
alias: alias:
description: | description: |
The alias for the extension. For example, The alias for the extension. For example,
@ -1205,6 +1268,12 @@ description_9:
in: body in: body
required: true required: true
type: string type: string
detail_id_body:
in: body
required: true
type: string
description: >
The ID of the message detail.
disabled: disabled:
description: | description: |
Indicates whether the service is disabled. Indicates whether the service is disabled.
@ -1782,6 +1851,24 @@ maxTotalSnapshotGigabytes:
in: body in: body
required: true required: true
type: integer 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: metadata:
description: | description: |
One or more metadata key and value pairs as a One or more metadata key and value pairs as a
@ -2114,6 +2201,12 @@ project_id_9:
in: body in: body
required: true required: true
type: string 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: protocol:
description: | description: |
The Shared File Systems protocol of the share to The Shared File Systems protocol of the share to
@ -2292,6 +2385,12 @@ replication_type:
in: body in: body
required: false required: false
type: string 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: required_extra_specs:
description: | description: |
The required extra specifications for the share The required extra specifications for the share
@ -2312,6 +2411,18 @@ reset_status:
in: body in: body
required: true required: true
type: object 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: security_service_created_at:
description: | description: |
The date and time stamp when the security service was created. The date and time stamp when the security service was created.

View File

@ -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"
}
}

View File

@ -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"
}
]
}

View File

@ -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

View File

@ -35,6 +35,7 @@ Programming HowTos and Tutorials
adding_release_notes adding_release_notes
commit_message_tags commit_message_tags
guru_meditation_report guru_meditation_report
user_messages
Background Concepts for manila Background Concepts for manila

View File

@ -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:

View File

@ -155,5 +155,9 @@
"share_group_types_spec:update": "rule:admin_api", "share_group_types_spec:update": "rule:admin_api",
"share_group_types_spec:show": "rule:admin_api", "share_group_types_spec:show": "rule:admin_api",
"share_group_types_spec:index": "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"
} }

View File

@ -106,6 +106,7 @@ REST_API_VERSION_HISTORY = """
and export_location_path. and export_location_path.
* 2.36 - Added like filter support in ``shares``, ``snapshots``, * 2.36 - Added like filter support in ``shares``, ``snapshots``,
``share-networks``, ``share-groups`` list APIs. ``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 # The default api version request is defined to be the
# minimum version of the API supported. # minimum version of the API supported.
_MIN_API_VERSION = "2.0" _MIN_API_VERSION = "2.0"
_MAX_API_VERSION = "2.36" _MAX_API_VERSION = "2.37"
DEFAULT_API_VERSION = _MIN_API_VERSION DEFAULT_API_VERSION = _MIN_API_VERSION

View File

@ -210,3 +210,7 @@ user documentation.
---- ----
Added like filter support in ``shares``, ``snapshots``, ``share-networks``, Added like filter support in ``shares``, ``snapshots``, ``share-networks``,
``share-groups`` list APIs. ``share-groups`` list APIs.
2.37
----
Added /messages APIs.

95
manila/api/v2/messages.py Normal file
View File

@ -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/<message_id>
DELETE /messages/<message_id>
"""
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())

View File

@ -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_types_extra_specs
from manila.api.v1 import share_unmanage from manila.api.v1 import share_unmanage
from manila.api.v2 import availability_zones 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_class_sets
from manila.api.v2 import quota_sets from manila.api.v2 import quota_sets
from manila.api.v2 import services from manila.api.v2 import services
@ -410,3 +411,7 @@ class APIRouter(manila.api.openstack.APIRouter):
controller=self.resources['share-replicas'], controller=self.resources['share-replicas'],
collection={'detail': 'GET'}, collection={'detail': 'GET'},
member={'action': 'POST'}) member={'action': 'POST'})
self.resources['messages'] = messages.create_resource()
mapper.resource("message", "messages",
controller=self.resources['messages'])

View File

@ -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

View File

@ -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( return IMPL.share_group_type_specs_update_or_create(
context, type_id, group_specs) 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)

View File

@ -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

View File

@ -4569,3 +4569,66 @@ def share_group_type_specs_update_or_create(context, type_id, specs):
spec_ref.save(session=session) spec_ref.save(session=session)
return specs 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())

View File

@ -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(): def register_models():
"""Register Models and create metadata. """Register Models and create metadata.

View File

@ -206,6 +206,10 @@ class NotFound(ManilaException):
safe = True safe = True
class MessageNotFound(NotFound):
message = _("Message %(message_id)s could not be found.")
class Found(ManilaException): class Found(ManilaException):
message = _("Resource was found.") message = _("Resource was found.")
code = 302 code = 302

View File

85
manila/message/api.py Normal file
View File

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

View File

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

View File

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

View File

@ -33,6 +33,7 @@ import manila.coordination
import manila.db.api import manila.db.api
import manila.db.base import manila.db.base
import manila.exception import manila.exception
import manila.message.api
import manila.network import manila.network
import manila.network.linux.interface import manila.network.linux.interface
import manila.network.neutron.api import manila.network.neutron.api
@ -102,6 +103,7 @@ _global_opt_lists = [
manila.db.api.db_opts, manila.db.api.db_opts,
[manila.db.base.db_driver_opt], [manila.db.base.db_driver_opt],
manila.exception.exc_log_opts, manila.exception.exc_log_opts,
manila.message.api.messages_opts,
manila.network.linux.interface.OPTS, manila.network.linux.interface.OPTS,
manila.network.network_opts, manila.network.network_opts,
manila.network.neutron.neutron_network_plugin. manila.network.neutron.neutron_network_plugin.

View File

@ -30,6 +30,8 @@ from manila import context
from manila import db from manila import db
from manila import exception from manila import exception
from manila import manager 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 quota
from manila import rpc from manila import rpc
from manila.share import rpcapi as share_rpcapi from manila.share import rpcapi as share_rpcapi
@ -77,6 +79,7 @@ class SchedulerManager(manager.Manager):
scheduler_driver = MAPPING[scheduler_driver] scheduler_driver = MAPPING[scheduler_driver]
self.driver = importutils.import_object(scheduler_driver) self.driver = importutils.import_object(scheduler_driver)
self.message_api = message_api.API()
super(self.__class__, self).__init__(*args, **kwargs) super(self.__class__, self).__init__(*args, **kwargs)
def init_host(self): def init_host(self):
@ -106,15 +109,14 @@ class SchedulerManager(manager.Manager):
self.driver.schedule_create_share(context, request_spec, self.driver.schedule_create_share(context, request_spec,
filter_properties) filter_properties)
except exception.NoValidHost as ex: except exception.NoValidHost as ex:
self._set_share_state_and_notify('create_share', self._set_share_state_and_notify(
{'status': 'create_share', {'status': constants.STATUS_ERROR},
constants.STATUS_ERROR}, context, ex, request_spec,
context, ex, request_spec) message_field.Action.ALLOCATE_HOST)
except Exception as ex: except Exception as ex:
with excutils.save_and_reraise_exception(): with excutils.save_and_reraise_exception():
self._set_share_state_and_notify('create_share', self._set_share_state_and_notify(
{'status': 'create_share', {'status': constants.STATUS_ERROR},
constants.STATUS_ERROR},
context, ex, request_spec) context, ex, request_spec)
def get_pools(self, context, filters=None): def get_pools(self, context, filters=None):
@ -188,7 +190,7 @@ class SchedulerManager(manager.Manager):
_migrate_share_set_error(self, context, ex, request_spec) _migrate_share_set_error(self, context, ex, request_spec)
def _set_share_state_and_notify(self, method, state, context, ex, 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", LOG.error("Failed to schedule %(method)s: %(ex)s",
{"method": method, "ex": ex}) {"method": method, "ex": ex})
@ -200,6 +202,12 @@ class SchedulerManager(manager.Manager):
if share_id: if share_id:
db.share_update(context, share_id, state) 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, payload = dict(request_spec=request_spec,
share_properties=properties, share_properties=properties,
share_id=share_id, share_id=share_id,

View File

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

View File

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

View File

@ -2364,3 +2364,35 @@ class SquashSGSnapshotMembersAndSSIModelsChecks(BaseMigrationChecks):
db_result = engine.execute(ssi_table.select().where( db_result = engine.execute(ssi_table.select().where(
ssi_table.c.id == self.share_group_snapshot_member_id)) ssi_table.c.id == self.share_group_snapshot_member_id))
self.test_case.assertEqual(0, db_result.rowcount) 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)

View File

@ -2738,3 +2738,89 @@ class ShareTypeAPITestCase(test.TestCase):
result = db_api.share_type_get_by_name_or_id(self.ctxt, fake_id) result = db_api.share_type_get_by_name_or_id(self.ctxt, fake_id)
self.assertIsNone(result) 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)

View File

@ -18,6 +18,7 @@ import copy
from manila.common import constants from manila.common import constants
from manila import context from manila import context
from manila import db from manila import db
from manila.message import message_levels
def _create_db_row(method, default_values, custom_values): def _create_db_row(method, default_values, custom_values):
@ -264,3 +265,12 @@ def create_security_service(**kwargs):
share_network_id, share_network_id,
service_ref['id']) service_ref['id'])
return service_ref 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)

View File

View File

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

View File

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

View File

@ -130,5 +130,9 @@
"share_group_types_spec:update": "rule:admin_api", "share_group_types_spec:update": "rule:admin_api",
"share_group_types_spec:show": "rule:admin_api", "share_group_types_spec:show": "rule:admin_api",
"share_group_types_spec:index": "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"
} }

View File

@ -31,6 +31,7 @@ from manila.common import constants
from manila import context from manila import context
from manila import db from manila import db
from manila import exception from manila import exception
from manila.message import message_field
from manila import quota from manila import quota
from manila.scheduler.drivers import base from manila.scheduler.drivers import base
from manila.scheduler.drivers import filter from manila.scheduler.drivers import filter
@ -136,7 +137,9 @@ class SchedulerManagerTestCase(test.TestCase):
assert_called_once_with(service_name, host, capabilities)) assert_called_once_with(service_name, host, capabilities))
@mock.patch.object(db, 'share_update', mock.Mock()) @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. """Test NoValidHost exception for create_share.
Puts the share in 'error' state and eats the exception. Puts the share in 'error' state and eats the exception.
@ -144,9 +147,10 @@ class SchedulerManagerTestCase(test.TestCase):
fake_share_id = 1 fake_share_id = 1
request_spec = {'share_id': fake_share_id} request_spec = {'share_id': fake_share_id}
ex = exception.NoValidHost(reason='')
with mock.patch.object( with mock.patch.object(
self.manager.driver, 'schedule_create_share', 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.mock_object(manager.LOG, 'error')
self.manager.create_share_instance( self.manager.create_share_instance(
@ -158,6 +162,12 @@ class SchedulerManagerTestCase(test.TestCase):
assert_called_once_with(self.context, request_spec, {})) assert_called_once_with(self.context, request_spec, {}))
manager.LOG.error.assert_called_once_with(mock.ANY, mock.ANY) 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()) @mock.patch.object(db, 'share_update', mock.Mock())
def test_create_share_other_exception_puts_share_in_error_state(self): def test_create_share_other_exception_puts_share_in_error_state(self):
"""Test any exception except NoValidHost for create_share. """Test any exception except NoValidHost for create_share.

View File

@ -30,7 +30,7 @@ ShareGroup = [
help="The minimum api microversion is configured to be the " help="The minimum api microversion is configured to be the "
"value of the minimum microversion supported by Manila."), "value of the minimum microversion supported by Manila."),
cfg.StrOpt("max_api_microversion", cfg.StrOpt("max_api_microversion",
default="2.36", default="2.37",
help="The maximum api microversion is configured to be the " help="The maximum api microversion is configured to be the "
"value of the latest microversion supported by Manila."), "value of the latest microversion supported by Manila."),
cfg.StrOpt("region", cfg.StrOpt("region",

View File

@ -186,6 +186,9 @@ class SharesV2Client(shares_client.SharesClient):
elif "replica_id" in kwargs: elif "replica_id" in kwargs:
return self._is_resource_deleted( return self._is_resource_deleted(
self.get_share_replica, kwargs.get("replica_id")) 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: else:
return super(SharesV2Client, self).is_resource_deleted( return super(SharesV2Client, self).is_resource_deleted(
*args, **kwargs) *args, **kwargs)
@ -1673,3 +1676,44 @@ class SharesV2Client(shares_client.SharesClient):
"snapshots/%s/export-locations" % snapshot_id, version=version) "snapshots/%s/export-locations" % snapshot_id, version=version)
self.expected_success(200, resp.status) self.expected_success(200, resp.status)
return self._parse_resp(body) 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)

View File

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

View File

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

View File

@ -998,6 +998,25 @@ class BaseSharesTest(test.BaseTestCase):
"d2value": d2value "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): class BaseSharesAltTest(BaseSharesTest):
"""Base test case class for all Shares Alt API tests.""" """Base test case class for all Shares Alt API tests."""

View File

@ -0,0 +1,7 @@
---
features:
- Added new user messages API - GET /messages, GET /messages/<message_id>
and DELETE /messages/<message_id>.
- Added sorting, filtering and pagination to the user messages listing.
- Added 'message_ttl' configuration option which can be used for
configuring message expiration time.