User messages API for error cases

This patch implements basic user messages with the following APIs.
GET /messages
GET /messages/<message_id>
DELETE /messages/<message_id>

Implements : blueprint summarymessage

Co-Authored-By: Alex Meade <mr.alex.meade@gmail.com>
Co-Authored-By: Sheel Rana <ranasheel2000@gmail.com>

Change-Id: Id8a4a700c1159be24b15056f401a2ea77804d0a0
This commit is contained in:
Alex Meade 2016-04-24 14:41:19 +05:30
parent db57966c1f
commit 53cfde43b8
31 changed files with 965 additions and 48 deletions

View File

@ -49,6 +49,7 @@ REST_API_VERSION_HISTORY = """
* 3.1 - Adds visibility and protected to _volume_upload_image parameters.
* 3.2 - Bootable filters in volume GET call no longer treats all values
passed to it as true.
* 3.3 - Add user messages APIs.
"""
@ -57,7 +58,7 @@ REST_API_VERSION_HISTORY = """
# minimum version of the API supported.
# Explicitly using /v1 or /v2 enpoints will still work
_MIN_API_VERSION = "3.0"
_MAX_API_VERSION = "3.2"
_MAX_API_VERSION = "3.3"
_LEGACY_API_VERSION1 = "1.0"
_LEGACY_API_VERSION2 = "2.0"

View File

@ -51,3 +51,7 @@ user documentation.
bootable filter values.
But for any other values passed for bootable filter, it will return
"Invalid input received: bootable={filter value}' error.
3.3
---
Added /messages API.

106
cinder/api/v3/messages.py Normal file
View File

@ -0,0 +1,106 @@
# 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."""
from oslo_config import cfg
from oslo_log import log as logging
import webob
from webob import exc
from cinder.api.openstack import wsgi
from cinder.api.v3.views import messages as messages_view
from cinder import exception
from cinder.message import api as message_api
from cinder.message import defined_messages
import cinder.policy
CONF = cfg.CONF
LOG = logging.getLogger(__name__)
MESSAGES_BASE_MICRO_VERSION = '3.3'
def check_policy(context, action, target_obj=None):
target = {
'project_id': context.project_id,
'user_id': context.user_id,
}
target.update(target_obj or {})
_action = 'message:%s' % action
cinder.policy.enforce(context, _action, target)
class MessagesController(wsgi.Controller):
"""The User Messages API controller for the OpenStack API."""
_view_builder_class = messages_view.ViewBuilder
def __init__(self, ext_mgr):
self.message_api = message_api.API()
self.ext_mgr = ext_mgr
super(MessagesController, self).__init__()
@wsgi.Controller.api_version(MESSAGES_BASE_MICRO_VERSION)
def show(self, req, id):
"""Return the given message."""
context = req.environ['cinder.context']
try:
message = self.message_api.get(context, id)
except exception.MessageNotFound as error:
raise exc.HTTPNotFound(explanation=error.msg)
check_policy(context, 'get', message)
# Fetches message text based on event id passed to it.
message['user_message'] = defined_messages.get_message_text(
message['event_id'])
return self._view_builder.detail(req, message)
@wsgi.Controller.api_version(MESSAGES_BASE_MICRO_VERSION)
def delete(self, req, id):
"""Delete a message."""
context = req.environ['cinder.context']
try:
message = self.message_api.get(context, id)
check_policy(context, 'delete', message)
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)
def index(self, req):
"""Returns a list of messages, transformed through view builder."""
context = req.environ['cinder.context']
check_policy(context, 'get_all')
messages = self.message_api.get_all(context)
for message in messages:
# Fetches message text based on event id passed to it.
user_message = defined_messages.get_message_text(
message['event_id'])
message['user_message'] = user_message
messages = self._view_builder.index(req, messages)
return messages
def create_resource(ext_mgr):
return wsgi.Resource(MessagesController(ext_mgr))

View File

@ -28,6 +28,7 @@ from cinder.api.v2 import snapshot_metadata
from cinder.api.v2 import snapshots
from cinder.api.v2 import types
from cinder.api.v2 import volume_metadata
from cinder.api.v3 import messages
from cinder.api.v3 import volumes
from cinder.api import versions
@ -53,6 +54,11 @@ class APIRouter(cinder.api.openstack.APIRouter):
collection={'detail': 'GET'},
member={'action': 'POST'})
self.resources['messages'] = messages.create_resource(ext_mgr)
mapper.resource("message", "messages",
controller=self.resources['messages'],
collection={'detail': 'GET'})
self.resources['types'] = types.create_resource()
mapper.resource("type", "types",
controller=self.resources['types'],

View File

View File

@ -0,0 +1,69 @@
# 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 cinder.api import common
class ViewBuilder(common.ViewBuilder):
"""Model a server API response as a python dictionary."""
_collection_name = "messages"
def index(self, request, messages, message_count=None):
"""Show a list of messages."""
return self._list_view(self.detail, request, messages, message_count)
def detail(self, request, message):
"""Detailed view of a single message."""
message_ref = {
'id': message.get('id'),
'event_id': message.get('event_id'),
'user_message': message.get('user_message'),
'message_level': message.get('message_level'),
'created_at': message.get('created_at'),
'guaranteed_until': message.get('expires_at'),
'request_id': message.get('request_id'),
'links': self._get_links(request, message['id']),
}
if message.get('resource_type'):
message_ref['resource_type'] = message.get('resource_type')
if message.get('resource_uuid'):
message_ref['resource_uuid'] = message.get('resource_uuid')
return {'message': message_ref}
def _list_view(self, func, request, messages, message_count=None,
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 message_count: Length of the original list of messages
: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,
message_count)
messages_dict = dict(messages=messages_list)
if messages_links:
messages_dict['messages_links'] = messages_links
return messages_dict

View File

@ -1121,6 +1121,28 @@ def image_volume_cache_get_all_for_host(context, host):
###################
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):
return IMPL.message_get_all(context)
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)
###################
def get_model_for_versioned_object(versioned_object):
return IMPL.get_model_for_versioned_object(versioned_object)

View File

@ -4271,6 +4271,76 @@ def purge_deleted_rows(context, age_in_days):
###############################
def _translate_messages(messages):
return [_translate_message(message) for message in messages]
def _translate_message(message):
"""Translate the Message model to a dict."""
return {
'id': message['id'],
'project_id': message['project_id'],
'request_id': message['request_id'],
'resource_type': message['resource_type'],
'resource_uuid': message.get('resource_uuid'),
'event_id': message['event_id'],
'message_level': message['message_level'],
'created_at': message['created_at'],
'expires_at': message.get('expires_at'),
}
@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 _translate_message(result)
@require_context
def message_get_all(context):
"""Fetch all messages for the contexts project."""
messages = models.Message
query = (model_query(context,
messages,
read_deleted="no",
project_only="yes"))
results = query.all()
return _translate_messages(results)
@require_context
def message_create(context, values):
message_ref = models.Message()
if not values.get('id'):
values['id'] = str(uuid.uuid4())
message_ref.update(values)
session = get_session()
with session.begin():
session.add(message_ref)
@require_admin_context
def message_destroy(context, message):
session = get_session()
now = timeutils.utcnow()
with session.begin():
(model_query(context, models.Message, session=session).
filter_by(id=message.get('id')).
update({'deleted': True,
'deleted_at': now,
'updated_at': literal_column('updated_at')}))
###############################
@require_context
def driver_initiator_data_update(context, initiator, namespace, updates):
session = get_session()

View File

@ -0,0 +1,40 @@
# 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 sqlalchemy import Boolean, Column, DateTime
from sqlalchemy import MetaData, String, Table
def upgrade(migrate_engine):
meta = MetaData()
meta.bind = migrate_engine
# New table
messages = Table(
'messages',
meta,
Column('id', String(36), primary_key=True, nullable=False),
Column('project_id', String(36), nullable=False),
Column('request_id', String(255), nullable=False),
Column('resource_type', String(36)),
Column('resource_uuid', String(255), nullable=True),
Column('event_id', String(255), nullable=False),
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', Boolean),
Column('expires_at', DateTime(timezone=False)),
mysql_engine='InnoDB'
)
messages.create()

View File

@ -599,6 +599,24 @@ class DriverInitiatorData(BASE, models.TimestampMixin, models.ModelBase):
value = Column(String(255))
class Message(BASE, CinderBase):
"""Represents a message"""
__tablename__ = 'messages'
id = Column(String(36), primary_key=True, nullable=False)
project_id = Column(String(36), 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_uuid = Column(String(36), nullable=True)
# Operation specific event ID.
event_id = Column(String(255), nullable=False)
created_at = Column(DateTime, default=lambda: timeutils.utcnow())
# After this time the message may no longer exist
expires_at = Column(DateTime, nullable=True)
class ImageVolumeCacheEntry(BASE, models.ModelBase):
"""Represents an image volume cache entry"""
__tablename__ = 'image_volume_cache_entries'

View File

@ -286,6 +286,10 @@ class VolumeNotFound(NotFound):
message = _("Volume %(volume_id)s could not be found.")
class MessageNotFound(NotFound):
message = _("Message %(message_id)s could not be found.")
class VolumeAttachmentNotFound(NotFound):
message = _("Volume attachment could not be found with "
"filter: %(filter)s .")
@ -532,6 +536,10 @@ class BackupLimitExceeded(QuotaError):
message = _("Maximum number of backups allowed (%(allowed)d) exceeded")
class ImageLimitExceeded(QuotaError):
message = _("Image quota exceeded")
class DuplicateSfVolumeNames(Duplicate):
message = _("Detected more than one volume with name %(vol_name)s")

View File

@ -219,6 +219,8 @@ class GlanceClientWrapper(object):
'method': method,
'extra': extra})
time.sleep(1)
except glanceclient.exc.HTTPOverLimit as e:
raise exception.ImageLimitExceeded(e)
class GlanceImageService(object):

View File

75
cinder/message/api.py Normal file
View File

@ -0,0 +1,75 @@
# 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
from cinder.db import base
from cinder.i18n import _LE, _LI
from cinder.message import defined_messages
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, event_id, project_id, resource_type=None,
resource_uuid=None, level="ERROR"):
"""Create a message with the specified information."""
LOG.info(_LI("Creating message record for request_id = %s"),
context.request_id)
# Ensure valid event_id
defined_messages.get_message_text(event_id)
# Updates expiry time for message as per message_ttl config.
expires_at = (timeutils.utcnow() + datetime.timedelta(
seconds=CONF.message_ttl))
message_record = {'project_id': project_id,
'request_id': context.request_id,
'resource_type': resource_type,
'resource_uuid': resource_uuid,
'event_id': event_id,
'message_level': level,
'expires_at': expires_at}
try:
self.db.message_create(context, message_record)
except Exception:
LOG.exception(_LE("Failed to create message record "
"for request_id %s"), context.request_id)
def get(self, context, id):
"""Return message with the specified id."""
return self.db.message_get(context, id)
def get_all(self, context):
"""Return all messages for the given context."""
messages = self.db.message_get_all(context)
return messages
def delete(self, context, id):
"""Delete message with the specified id."""
ctx = context.elevated()
return self.db.message_destroy(ctx, id)

View File

@ -0,0 +1,44 @@
# 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.
"""Event ID and user visible message mapping.
Event IDs are used to look up the message to be displayed for an API Message
object. All defined messages should be appropriate for any API user to see
and not contain any sensitive information. A good rule-of-thumb is to be very
general in error messages unless the issue is due to a bad user action, then be
specific.
"""
from cinder.i18n import _
UNKNOWN_ERROR = '000001'
UNABLE_TO_ALLOCATE = '000002'
ATTACH_READONLY_VOLUME = '000003'
IMAGE_FROM_VOLUME_OVER_QUOTA = '000004'
event_id_message_map = {
UNKNOWN_ERROR: _("An unknown error occurred."),
UNABLE_TO_ALLOCATE: _("No storage could be allocated for this volume "
"request. You may be able to try another size or"
" volume type."),
ATTACH_READONLY_VOLUME: _("A readonly volume must be attached as "
"readonly."),
IMAGE_FROM_VOLUME_OVER_QUOTA: _("Failed to copy volume to image as image "
"quota has been met. Please delete images"
" or have your limit increased, then try "
"again."),
}
def get_message_text(event_id):
return event_id_message_map[event_id]

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.
"""Resource type constants."""
VOLUME = 'VOLUME'

View File

@ -44,6 +44,7 @@ from cinder.image import image_utils as cinder_image_imageutils
import cinder.keymgr
from cinder.keymgr import conf_key_mgr as cinder_keymgr_confkeymgr
from cinder.keymgr import key_mgr as cinder_keymgr_keymgr
from cinder.message import api as cinder_message_api
from cinder import quota as cinder_quota
from cinder.scheduler import driver as cinder_scheduler_driver
from cinder.scheduler import host_manager as cinder_scheduler_hostmanager
@ -311,6 +312,7 @@ def list_opts():
cinder_volume_drivers_zfssa_zfssanfs.ZFSSA_OPTS,
cinder_volume_drivers_disco_disco.disco_opts,
cinder_volume_drivers_hgst.hgst_opts,
cinder_message_api.messages_opts,
cinder_image_imageutils.image_helper_opts,
cinder_compute_nova.nova_opts,
cinder_volume_drivers_ibm_flashsystemfc.flashsystem_fc_opts,

View File

@ -18,6 +18,9 @@ from taskflow.patterns import linear_flow
from cinder import exception
from cinder import flow_utils
from cinder.i18n import _LE
from cinder.message import api as message_api
from cinder.message import defined_messages
from cinder.message import resource_types
from cinder import rpc
from cinder import utils
from cinder.volume.flows import common
@ -90,6 +93,7 @@ class ScheduleCreateVolumeTask(flow_utils.CinderTask):
**kwargs)
self.db_api = db_api
self.driver_api = driver_api
self.message_api = message_api.API()
def _handle_failure(self, context, request_spec, cause):
try:
@ -127,6 +131,13 @@ class ScheduleCreateVolumeTask(flow_utils.CinderTask):
# reraise (since what's the point?)
with excutils.save_and_reraise_exception(
reraise=not isinstance(e, exception.NoValidHost)):
if isinstance(e, exception.NoValidHost):
self.message_api.create(
context,
defined_messages.UNABLE_TO_ALLOCATE,
context.project_id,
resource_type=resource_types.VOLUME,
resource_uuid=request_spec['volume_id'])
try:
self._handle_failure(context, request_spec, e)
finally:

View File

@ -0,0 +1,42 @@
# 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 cinder.message import defined_messages
from cinder.tests.unit import fake_constants
FAKE_UUID = fake_constants.object_id
def stub_message(id, **kwargs):
message = {
'id': id,
'event_id': defined_messages.UNABLE_TO_ALLOCATE,
'message_level': "ERROR",
'request_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()),
}
message.update(kwargs)
return message
def stub_message_get(self, context, message_id):
return stub_message(message_id)

View File

@ -0,0 +1,140 @@
# 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 cinder.api import extensions
from cinder.api.v3 import messages
from cinder import context
from cinder import exception
from cinder.message import api as message_api
from cinder.message import defined_messages
from cinder import test
from cinder.tests.unit.api import fakes
from cinder.tests.unit.api.v3 import stubs
CONF = cfg.CONF
NS = '{http://docs.openstack.org/api/openstack-block-storage/3.0/content}'
class MessageApiTest(test.TestCase):
def setUp(self):
super(MessageApiTest, self).setUp()
self.ext_mgr = extensions.ExtensionManager()
self.ext_mgr.extensions = {}
self.controller = messages.MessagesController(self.ext_mgr)
self.maxDiff = None
self.ctxt = context.RequestContext('admin', 'fakeproject', True)
def _expected_message_from_controller(self, id):
message = stubs.stub_message(id)
links = [
{'href': 'http://localhost/v3/fakeproject/messages/%s' % id,
'rel': 'self'},
{'href': 'http://localhost/fakeproject/messages/%s' % id,
'rel': 'bookmark'},
]
return {
'message': {
'id': message.get('id'),
'user_message': defined_messages.get_message_text(
message.get('event_id')),
'request_id': message.get('request_id'),
'event_id': message.get('event_id'),
'created_at': message.get('created_at'),
'message_level': message.get('message_level'),
'guaranteed_until': message.get('expires_at'),
'links': links,
}
}
def test_show(self):
self.stubs.Set(message_api.API, 'get', stubs.stub_message_get)
req = fakes.HTTPRequest.blank(
'/v3/messages/%s' % fakes.FAKE_UUID,
version=messages.MESSAGES_BASE_MICRO_VERSION)
req.environ['cinder.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_not_found(self):
self.stubs.Set(message_api.API, 'get',
mock.Mock(side_effect=exception.MessageNotFound(
message_id=fakes.FAKE_UUID)))
req = fakes.HTTPRequest.blank(
'/v3/messages/%s' % fakes.FAKE_UUID,
version=messages.MESSAGES_BASE_MICRO_VERSION)
req.environ['cinder.context'] = self.ctxt
self.assertRaises(webob.exc.HTTPNotFound, self.controller.show,
req, fakes.FAKE_UUID)
def test_show_pre_microversion(self):
self.stubs.Set(message_api.API, 'get', stubs.stub_message_get)
req = fakes.HTTPRequest.blank('/v3/messages/%s' % fakes.FAKE_UUID,
version='3.0')
req.environ['cinder.context'] = self.ctxt
self.assertRaises(exception.VersionNotFoundForAPIMethod,
self.controller.show, req, fakes.FAKE_UUID)
def test_delete(self):
self.stubs.Set(message_api.API, 'get', stubs.stub_message_get)
self.stubs.Set(message_api.API, 'delete', mock.Mock())
req = fakes.HTTPRequest.blank(
'/v3/messages/%s' % fakes.FAKE_UUID,
version=messages.MESSAGES_BASE_MICRO_VERSION)
req.environ['cinder.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):
self.stubs.Set(message_api.API, 'get',
mock.Mock(side_effect=exception.MessageNotFound(
message_id=fakes.FAKE_UUID)))
req = fakes.HTTPRequest.blank(
'/v3/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):
self.stubs.Set(message_api.API, 'get_all', mock.Mock(
return_value=[stubs.stub_message(fakes.FAKE_UUID)]))
req = fakes.HTTPRequest.blank(
'/v3/messages/%s' % fakes.FAKE_UUID,
version=messages.MESSAGES_BASE_MICRO_VERSION)
req.environ['cinder.context'] = self.ctxt
res_dict = self.controller.index(req)
ex = self._expected_message_from_controller(fakes.FAKE_UUID)
expected = {
'messages': [ex['message']]
}
self.assertDictMatch(expected, res_dict)

View File

@ -824,6 +824,20 @@ class TestGlanceClientVersion(test.TestCase):
self.assertEqual('2', _mockglanceclient.call_args[0][0])
@mock.patch('cinder.image.glance.glanceclient.Client')
@mock.patch('cinder.image.glance.get_api_servers',
return_value=itertools.cycle([(False, 'localhost:9292')]))
def test_call_glance_over_quota(self, api_servers, _mockglanceclient):
"""Test glance version set by arg to GlanceClientWrapper"""
glance_wrapper = glance.GlanceClientWrapper()
fake_client = mock.Mock()
fake_client.images.method = mock.Mock(
side_effect=glanceclient.exc.HTTPOverLimit)
self.mock_object(glance_wrapper, 'client', fake_client)
self.assertRaises(exception.ImageLimitExceeded,
glance_wrapper.call, 'fake_context', 'method',
version=2)
def _create_failing_glance_client(info):
class MyGlanceStubClient(glance_stubs.StubGlanceClient):

View File

View File

@ -0,0 +1,94 @@
# 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 cinder import context
from cinder.message import api as message_api
from cinder.message import defined_messages
from cinder 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_uuid': None,
'event_id': defined_messages.UNABLE_TO_ALLOCATE,
'message_level': 'ERROR',
'expires_at': expected_expires_at,
}
self.message_api.create(self.ctxt,
defined_messages.UNABLE_TO_ALLOCATE,
"fakeproject",
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, 'create',
mock.Mock(side_effect=Exception()))
self.message_api.create(self.ctxt,
defined_messages.UNABLE_TO_ALLOCATE,
"fakeproject",
"fake_resource")
self.message_api.db.message_create.assert_called_once_with(
self.ctxt, mock.ANY)
def test_create_does_not_allow_undefined_messages(self):
self.assertRaises(KeyError, self.message_api.create,
self.ctxt,
"FAKE_EVENT_ID",
"fakeproject",
"fake_resource")
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)
def test_delete(self):
admin_context = mock.Mock()
self.mock_object(self.ctxt, 'elevated',
mock.Mock(return_value=admin_context))
self.message_api.delete(self.ctxt, 'fake_id')
self.message_api.db.message_destroy.assert_called_once_with(
admin_context, 'fake_id')

View File

@ -107,5 +107,9 @@
"consistencygroup:get_cgsnapshot": "",
"consistencygroup:get_all_cgsnapshots": "",
"scheduler_extension:scheduler_stats:get_pools" : "rule:admin_api"
"scheduler_extension:scheduler_stats:get_pools" : "rule:admin_api",
"message:delete": "rule:admin_or_owner",
"message:get": "rule:admin_or_owner",
"message:get_all": "rule:admin_or_owner"
}

View File

@ -23,6 +23,7 @@ from oslo_config import cfg
from cinder import context
from cinder import db
from cinder import exception
from cinder.message import defined_messages
from cinder.objects import fields
from cinder.scheduler import driver
from cinder.scheduler import filter_scheduler
@ -116,9 +117,11 @@ class SchedulerManagerTestCase(test.TestCase):
_mock_update_cap.assert_called_once_with(service, host, capabilities)
@mock.patch('cinder.scheduler.driver.Scheduler.schedule_create_volume')
@mock.patch('cinder.message.api.API.create')
@mock.patch('cinder.db.volume_update')
def test_create_volume_exception_puts_volume_in_error_state(
self, _mock_volume_update, _mock_sched_create):
self, _mock_volume_update, _mock_message_create,
_mock_sched_create):
# Test NoValidHost exception behavior for create_volume.
# Puts the volume in 'error' state and eats the exception.
_mock_sched_create.side_effect = exception.NoValidHost(reason="")
@ -136,6 +139,11 @@ class SchedulerManagerTestCase(test.TestCase):
_mock_sched_create.assert_called_once_with(self.context, request_spec,
{})
_mock_message_create.assert_called_once_with(
self.context, defined_messages.UNABLE_TO_ALLOCATE,
self.context.project_id, resource_type='VOLUME',
resource_uuid=volume.id)
@mock.patch('cinder.scheduler.driver.Scheduler.schedule_create_volume')
@mock.patch('eventlet.sleep')
def test_create_volume_no_delay(self, _mock_sleep, _mock_sched_create):

View File

@ -812,6 +812,33 @@ class MigrationsMixin(test_migrations.WalkVersionsMixin):
fkey, = iscsi_targets.c.volume_id.foreign_keys
self.assertIsNotNone(fkey)
def _check_074(self, engine, data):
"""Test adding message table."""
self.assertTrue(engine.dialect.has_table(engine.connect(),
"messages"))
messages = db_utils.get_table(engine, 'messages')
self.assertIsInstance(messages.c.created_at.type,
self.TIME_TYPE)
self.assertIsInstance(messages.c.deleted_at.type,
self.TIME_TYPE)
self.assertIsInstance(messages.c.deleted.type,
self.BOOL_TYPE)
self.assertIsInstance(messages.c.message_level.type,
self.VARCHAR_TYPE)
self.assertIsInstance(messages.c.project_id.type,
self.VARCHAR_TYPE)
self.assertIsInstance(messages.c.id.type,
self.VARCHAR_TYPE)
self.assertIsInstance(messages.c.request_id.type,
self.VARCHAR_TYPE)
self.assertIsInstance(messages.c.resource_uuid.type,
self.VARCHAR_TYPE)
self.assertIsInstance(messages.c.event_id.type,
self.VARCHAR_TYPE)
self.assertIsInstance(messages.c.resource_type.type,
self.VARCHAR_TYPE)
def test_walk_versions(self):
self.walk_versions(False, False)

View File

@ -47,6 +47,8 @@ from cinder import db
from cinder import exception
from cinder.image import image_utils
from cinder import keymgr
from cinder.message import defined_messages
from cinder.message import resource_types
from cinder import objects
from cinder.objects import fields
import cinder.policy
@ -130,6 +132,7 @@ class BaseVolumeTestCase(test.TestCase):
notification_driver=["test"])
self.addCleanup(self._cleanup)
self.volume = importutils.import_object(CONF.volume_manager)
self.volume.message_api = mock.Mock()
self.configuration = mock.Mock(conf.Configuration)
self.context = context.get_admin_context()
self.context.user_id = fake.user_id
@ -277,6 +280,7 @@ class AvailabilityZoneTestCase(BaseVolumeTestCase):
def test_list_availability_zones_refetched(self):
timeutils.set_time_override()
self.addCleanup(timeutils.clear_time_override)
volume_api = cinder.volume.api.API()
with mock.patch.object(volume_api.db,
'service_get_all_by_topic') as get_all:
@ -343,7 +347,6 @@ class AvailabilityZoneTestCase(BaseVolumeTestCase):
self.assertEqual(expected, azs)
@ddt.ddt
class VolumeTestCase(BaseVolumeTestCase):
def setUp(self):
@ -2879,6 +2882,13 @@ class VolumeTestCase(BaseVolumeTestCase):
None,
mountpoint,
'rw')
# Assert a user message was created
self.volume.message_api.create.assert_called_once_with(
self.context, defined_messages.ATTACH_READONLY_VOLUME,
self.context.project_id, resource_type=resource_types.VOLUME,
resource_uuid=volume['id'])
vol = db.volume_get(context.get_admin_context(), volume_id)
self.assertEqual('error_attaching', vol['status'])
self.assertEqual('detached', vol['attach_status'])
@ -4179,17 +4189,27 @@ class VolumeTestCase(BaseVolumeTestCase):
fake_new_volume.id)
self.assertIsNone(volume.migration_status)
@ddt.data(False, True)
def test_check_volume_filters(self, filter_value):
"""Test bootable as filter for True or False"""
def test_check_volume_filters_true(self):
"""Test bootable as filter for true"""
volume_api = cinder.volume.api.API()
filters = {'bootable': filter_value}
filters = {'bootable': 'TRUE'}
# To convert filter value to True or False
volume_api.check_volume_filters(filters)
# Confirming converted filter value against True or False
self.assertEqual(filter_value, filters['bootable'])
# Confirming converted filter value against True
self.assertTrue(filters['bootable'])
def test_check_volume_filters_false(self):
"""Test bootable as filter for false"""
volume_api = cinder.volume.api.API()
filters = {'bootable': 'false'}
# To convert filter value to True or False
volume_api.check_volume_filters(filters)
# Confirming converted filter value against False
self.assertEqual(False, filters['bootable'])
def test_check_volume_filters_invalid(self):
"""Test bootable as filter"""
@ -4202,43 +4222,6 @@ class VolumeTestCase(BaseVolumeTestCase):
# Confirming converted filter value against invalid value
self.assertTrue(filters['bootable'])
@ddt.data('False', 'false', 'f', '0')
def test_check_volume_filters_strict_false(self, filter_value):
"""Test bootable as filter for False, false, f and 0 values"""
volume_api = cinder.volume.api.API()
filters = {'bootable': filter_value}
strict = True
# To convert filter value to True or False
volume_api.check_volume_filters(filters, strict)
# Confirming converted filter value against False
self.assertFalse(filters['bootable'])
@ddt.data('True', 'true', 't', '1')
def test_check_volume_filters_strict_true(self, filter_value):
"""Test bootable as filter for True, true, t, 1 values"""
volume_api = cinder.volume.api.API()
filters = {'bootable': filter_value}
strict = True
# To convert filter value to True or False
volume_api.check_volume_filters(filters, strict)
# Confirming converted filter value against True
self.assertTrue(filters['bootable'])
def test_check_volume_filters_strict_invalid(self):
"""Test bootable as filter for invalid value."""
volume_api = cinder.volume.api.API()
filters = {'bootable': 'invalid'}
strict = True
# Confirming exception for invalid value in filter
self.assertRaises(exception.InvalidInput,
volume_api.check_volume_filters,
filters, strict)
def test_update_volume_readonly_flag(self):
"""Test volume readonly flag can be updated at API level."""
# create a volume and assign to host
@ -5989,6 +5972,27 @@ class CopyVolumeToImageTestCase(BaseVolumeTestCase):
volume = db.volume_get(self.context, self.volume_id)
self.assertEqual('available', volume['status'])
def test_copy_volume_to_image_over_image_quota(self):
# creating volume testdata
self.volume_attrs['instance_uuid'] = None
volume = db.volume_create(self.context, self.volume_attrs)
with mock.patch.object(self.volume.driver,
'copy_volume_to_image') as driver_copy_mock:
driver_copy_mock.side_effect = exception.ImageLimitExceeded
# test with image not in queued state
self.assertRaises(exception.ImageLimitExceeded,
self.volume.copy_volume_to_image,
self.context,
self.volume_id,
self.image_meta)
# Assert a user message was created
self.volume.message_api.create.assert_called_once_with(
self.context, defined_messages.IMAGE_FROM_VOLUME_OVER_QUOTA,
self.context.project_id, resource_type=resource_types.VOLUME,
resource_uuid=volume['id'])
def test_copy_volume_to_image_instance_deleted(self):
# During uploading volume to image if instance is deleted,
# volume should be in available status.

View File

@ -61,6 +61,9 @@ from cinder.i18n import _, _LE, _LI, _LW
from cinder.image import cache as image_cache
from cinder.image import glance
from cinder import manager
from cinder.message import api as message_api
from cinder.message import defined_messages
from cinder.message import resource_types
from cinder import objects
from cinder.objects import fields
from cinder import quota
@ -282,6 +285,7 @@ class VolumeManager(manager.SchedulerDependentManager):
host=self.host,
is_vol_db_empty=vol_db_empty,
active_backend_id=curr_active_backend_id)
self.message_api = message_api.API()
if CONF.profiler.enabled and profiler is not None:
self.driver = profiler.trace_cls("driver")(self.driver)
@ -1000,6 +1004,10 @@ class VolumeManager(manager.SchedulerDependentManager):
if volume_metadata.get('readonly') == 'True' and mode != 'ro':
self.db.volume_update(context, volume_id,
{'status': 'error_attaching'})
self.message_api.create(
context, defined_messages.ATTACH_READONLY_VOLUME,
context.project_id, resource_type=resource_types.VOLUME,
resource_uuid=volume_id)
raise exception.InvalidVolumeAttachMode(mode=mode,
volume_id=volume_id)
@ -1317,6 +1325,12 @@ class VolumeManager(manager.SchedulerDependentManager):
with excutils.save_and_reraise_exception():
payload['message'] = six.text_type(error)
if isinstance(error, exception.ImageLimitExceeded):
self.message_api.create(
context, defined_messages.IMAGE_FROM_VOLUME_OVER_QUOTA,
context.project_id,
resource_type=resource_types.VOLUME,
resource_uuid=volume_id)
finally:
self.db.volume_update_status_based_on_attachment(context,
volume_id)

View File

@ -34,6 +34,7 @@ Programming HowTos and Tutorials
drivers
gmr
replication
user_messages
migration
api.apache
rolling.upgrades

View File

@ -0,0 +1,73 @@
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 volume
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 cinder import context
from cinder.message import api as message_api
from cinder.message import defined_messages
from cinder.message import resource_types
self.message_api = message_api.API()
context = context.RequestContext()
project_id = '6c430ede-9476-4128-8838-8d3929ced223'
volume_id = 'f292cc0c-54a7-4b3b-8174-d2ff82d87008'
self.message_api.create(
context,
defined_messages.UNABLE_TO_ALLOCATE,
project_id,
resource_type=resource_types.VOLUME,
resource_uuid=volume_id)
Will produce the following::
GET /v3/6c430ede-9476-4128-8838-8d3929ced223/messages
{
"messages": [
{
"id": "5429fffa-5c76-4d68-a671-37a8e24f37cf",
"event_id": "000002",
"user_message": "No storage could be allocated for this volume request.",
"message_level": "ERROR",
"resource_type": "VOLUME",
"resource_uuid": "f292cc0c-54a7-4b3b-8174-d2ff82d87008",
"created_at": 2015-08-27T09:49:58-05:00,
"guaranteed_until": 2015-09-27T09:49:58-05:00,
"request_id": "req-936666d2-4c8f-4e41-9ac9-237b43f8b848",
}
]
}
The Message API Module
----------------------
.. automodule:: cinder.message.api
:noindex:
:members:
:undoc-members:
The Resource Types Module
-------------------------
.. automodule:: cinder.message.resource_types
:noindex:
The Permitted Messages Module
-----------------------------
.. automodule:: cinder.message.defined_messages
:noindex:
:members:
:undoc-members:
:show-inheritance:

View File

@ -102,5 +102,8 @@
"consistencygroup:get_cgsnapshot": "group:nobody",
"consistencygroup:get_all_cgsnapshots": "group:nobody",
"scheduler_extension:scheduler_stats:get_pools" : "rule:admin_api"
"scheduler_extension:scheduler_stats:get_pools" : "rule:admin_api",
"message:delete": "rule:admin_or_owner",
"message:get": "rule:admin_or_owner",
"message:get_all": "rule:admin_or_owner"
}