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:: os-share-manage.inc
.. include:: quota-sets.inc
.. include:: user-messages.inc
======================================
Shared File Systems API (EXPERIMENTAL)

View File

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

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
commit_message_tags
guru_meditation_report
user_messages
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: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"
}

View File

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

View File

@ -210,3 +210,7 @@ user documentation.
----
Added like filter support in ``shares``, ``snapshots``, ``share-networks``,
``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_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'])

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

View File

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

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

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
})
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."""

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.