Add commands for user messages

Allows listing, showing and deleting of user messages.

Change-Id: I5ffb840a271c518f62ee1accfd8e20a97f45594d
Partially-implements: blueprint user-messages
Depends-On: Ia0cc524e0bfb2ca5e495e575e17e9911c746690b
This commit is contained in:
Jan Provaznik 2017-06-04 02:26:32 +02:00 committed by Tom Barron
parent 757bb793a8
commit 8bf5f9b7e0
12 changed files with 681 additions and 1 deletions

View File

@ -27,7 +27,7 @@ from manilaclient import utils
LOG = logging.getLogger(__name__)
MAX_VERSION = '2.33'
MAX_VERSION = '2.37'
MIN_VERSION = '2.0'
DEPRECATED_VERSION = '1.0'
_VERSIONED_METHOD_MAP = {}

View File

@ -81,3 +81,8 @@ V2_SERVICE_TYPE = 'sharev2'
SERVICE_TYPES = {'1': V1_SERVICE_TYPE, '2': V2_SERVICE_TYPE}
EXTENSION_PLUGIN_NAMESPACE = 'manilaclient.common.apiclient.auth'
MESSAGE_SORT_KEY_VALUES = (
'id', 'project_id', 'request_id', 'resource_type', 'action_id',
'detail_id', 'resource_id', 'message_level', 'expires_at',
'request_id', 'created_at'
)

View File

@ -17,6 +17,7 @@ import traceback
from oslo_log import log
from tempest.lib.cli import base
from tempest.lib.common.utils import data_utils
from tempest.lib import exceptions as lib_exc
from manilaclient import config
@ -324,3 +325,40 @@ class BaseTestCase(base.ClientTestBase):
if wait_for_creation:
client.wait_for_snapshot_status(snapshot['id'], 'available')
return snapshot
@classmethod
def create_message(cls, client=None, wait_for_creation=True,
cleanup_in_class=False, microversion=None):
"""Trigger a 'no valid host' situation to generate a message."""
if client is None:
client = cls.get_admin_client()
extra_specs = {
'vendor_name': 'foobar',
}
share_type_name = data_utils.rand_name("share-type")
cls.create_share_type(
name=share_type_name, extra_specs=extra_specs,
driver_handles_share_servers=False, client=client,
cleanup_in_class=cleanup_in_class, microversion=microversion)
share_name = data_utils.rand_name("share")
share = cls.create_share(
name=share_name, share_type=share_type_name,
cleanup_in_class=cleanup_in_class, microversion=microversion,
wait_for_creation=False, client=client)
client.wait_for_share_status(share['id'], "error")
message = client.wait_for_message(share['id'])
resource = {
"type": "message",
"id": message["ID"],
"client": client,
"microversion": microversion,
}
if cleanup_in_class:
cls.class_resources.insert(0, resource)
else:
cls.method_resources.insert(0, resource)
return message

View File

@ -29,6 +29,7 @@ from manilaclient.tests.functional import exceptions
from manilaclient.tests.functional import utils
CONF = config.CONF
MESSAGE = 'message'
SHARE = 'share'
SHARE_TYPE = 'share_type'
SHARE_NETWORK = 'share_network'
@ -133,6 +134,8 @@ class ManilaCLIClient(base.CLIClient):
func = self.is_share_deleted
elif res_type == SNAPSHOT:
func = self.is_snapshot_deleted
elif res_type == MESSAGE:
func = self.is_message_deleted
else:
raise exceptions.InvalidResource(message=res_type)
@ -1363,3 +1366,70 @@ class ManilaCLIClient(base.CLIClient):
self.wait_for_resource_deletion(
SHARE_SERVER, res_id=share_server, interval=3, timeout=60,
microversion=microversion)
# user messages
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 tempest_lib_exc.TimeoutException(message)
def list_messages(self, columns=None, microversion=None):
"""List messages.
:param columns: str -- comma separated string of columns.
Example, "--columns id,resource_id".
:param microversion: API microversion to be used for request.
"""
cmd = "message-list"
if columns is not None:
cmd += " --columns " + columns
messages_raw = self.manila(cmd, microversion=microversion)
messages = utils.listing(messages_raw)
return messages
@not_found_wrapper
def get_message(self, message, microversion=None):
"""Returns share server by its Name or ID."""
message_raw = self.manila(
'message-show %s' % message, microversion=microversion)
message = output_parser.details(message_raw)
return message
@not_found_wrapper
def delete_message(self, message, microversion=None):
"""Deletes message by its ID."""
return self.manila('message-delete %s' % message,
microversion=microversion)
def is_message_deleted(self, message, microversion=None):
"""Indicates whether message is deleted or not.
:param message: str -- ID of message
"""
try:
self.get_message(message, microversion=microversion)
return False
except tempest_lib_exc.NotFound:
return True
def wait_for_message_deletion(self, message, microversion=None):
"""Wait for message deletion by its ID.
:param message: text -- ID of message
"""
self.wait_for_resource_deletion(
MESSAGE, res_id=message, interval=3, timeout=60,
microversion=microversion)

View File

@ -0,0 +1,71 @@
# 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 manilaclient.tests.functional import base
@ddt.ddt
class MessagesReadOnlyTest(base.BaseTestCase):
@ddt.data(
("admin", "2.37"),
("user", "2.37"),
)
@ddt.unpack
def test_message_list(self, role, microversion):
self.skip_if_microversion_not_supported(microversion)
self.clients[role].manila("message-list", microversion=microversion)
@ddt.ddt
class MessagesReadWriteTest(base.BaseTestCase):
@classmethod
def setUpClass(cls):
super(MessagesReadWriteTest, cls).setUpClass()
cls.message = cls.create_message(cleanup_in_class=True)
def test_list_messages(self):
self.skip_if_microversion_not_supported('2.37')
messages = self.admin_client.list_messages()
self.assertTrue(any(m['ID'] is not None for m in messages))
self.assertTrue(any(m['User Message'] is not None for m in messages))
self.assertTrue(any(m['Resource ID'] is not None for m in messages))
self.assertTrue(any(m['Action ID'] is not None for m in messages))
self.assertTrue(any(m['Detail ID'] is not None for m in messages))
self.assertTrue(any(m['Resource Type'] is not None for m in messages))
@ddt.data(
'id', 'action_id', 'resource_id', 'action_id', 'detail_id',
'resource_type', 'created_at', 'action_id,detail_id,resource_id',
)
def test_list_share_type_select_column(self, columns):
self.skip_if_microversion_not_supported('2.37')
self.admin_client.list_messages(columns=columns)
def test_get_message(self):
self.skip_if_microversion_not_supported('2.37')
message = self.admin_client.get_message(self.message['ID'])
expected_keys = (
'id', 'action_id', 'resource_id', 'action_id', 'detail_id',
'resource_type', 'created_at', 'created_at',
)
for key in expected_keys:
self.assertIn(key, message)
def test_delete_message(self):
self.skip_if_microversion_not_supported('2.37')
message = self.create_message(cleanup_in_class=False)
self.admin_client.delete_message(message['ID'])
self.admin_client.wait_for_message_deletion(message['ID'])

View File

@ -1095,6 +1095,35 @@ class FakeHTTPClient(fakes.FakeHTTPClient):
}
return 200, {}, sg_type_access
fake_message = {
'id': 'fake message id',
'action_id': '001',
'detail_id': '002',
'user_message': 'user message',
'message_level': 'ERROR',
'resource_type': 'SHARE',
'resource_id': 'resource id',
'created_at': '2015-08-27T09:49:58-05:00',
'expires_at': '2015-09-27T09:49:58-05:00',
'request_id': 'req-936666d2-4c8f-4e41-9ac9-237b43f8b848',
}
def get_messages(self, **kw):
messages = {
'messages': [self.fake_message],
}
return 200, {}, messages
def get_messages_1234(self, **kw):
message = {'message': self.fake_message}
return 200, {}, message
def delete_messages_1234(self, **kw):
return 202, {}, None
def delete_messages_5678(self, **kw):
return 202, {}, None
def fake_create(url, body, response_key):
return {'url': url, 'body': body, 'resp_key': response_key}
@ -1151,3 +1180,16 @@ class ShareGroupSnapshot(object):
share_network_id = ShareNetwork().id
name = 'fake name'
description = 'fake description'
class Message(object):
id = 'fake message id'
action_id = '001'
detail_id = '002'
user_message = 'user message'
message_level = 'ERROR'
resource_type = 'SHARE'
resource_id = 'resource id'
created_at = '2015-08-27T09:49:58-05:00'
expires_at = '2015-09-27T09:49:58-05:00'
request_id = 'req-936666d2-4c8f-4e41-9ac9-237b43f8b848'

View File

@ -0,0 +1,132 @@
# Copyright 2017 Red Hat
# All Rights Reserved.
#
# 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
import ddt
import six
from manilaclient.tests.unit import utils
from manilaclient.tests.unit.v2 import fakes as fake
from manilaclient.v2 import messages
class MessageTest(utils.TestCase):
def setUp(self):
super(MessageTest, self).setUp()
self.manager = messages.MessageManager(fake.FakeClient())
self.message = messages.Message(
self.manager, {'id': 'fake_id'})
self.fake_kwargs = {'key': 'value'}
def test_repr(self):
result = six.text_type(self.message)
self.assertEqual('<Message: fake_id>', result)
def test_delete(self):
mock_manager_delete = self.mock_object(self.manager, 'delete')
self.message.delete()
mock_manager_delete.assert_called_once_with(self.message)
@ddt.ddt
class MessageManagerTest(utils.TestCase):
def setUp(self):
super(MessageManagerTest, self).setUp()
self.manager = messages.MessageManager(fake.FakeClient())
def test_get(self):
fake_message = fake.Message()
mock_get = self.mock_object(
self.manager, '_get', mock.Mock(return_value=fake_message))
result = self.manager.get(fake.Message.id)
self.assertIs(fake_message, result)
mock_get.assert_called_once_with(
messages.RESOURCE_PATH % fake.Message.id,
messages.RESOURCE_NAME)
def test_list(self):
fake_message = fake.Message()
mock_list = self.mock_object(
self.manager, '_list', mock.Mock(return_value=[fake_message]))
result = self.manager.list()
self.assertEqual([fake_message], result)
mock_list.assert_called_once_with(
messages.RESOURCES_PATH,
messages.RESOURCES_NAME)
@ddt.data(
({'action_id': 1, 'resource_type': 'share'},
'?action_id=1&resource_type=share'),
({'action_id': 1}, '?action_id=1'),
)
@ddt.unpack
def test_list_with_filters(self, filters, filters_path):
fake_message = fake.Message()
mock_list = self.mock_object(
self.manager, '_list', mock.Mock(return_value=[fake_message]))
result = self.manager.list(search_opts=filters)
self.assertEqual([fake_message], result)
expected_path = (messages.RESOURCES_PATH + filters_path)
mock_list.assert_called_once_with(
expected_path, messages.RESOURCES_NAME)
@ddt.data('id', 'project_id', 'request_id', 'resource_type', 'action_id',
'detail_id', 'resource_id', 'message_level', 'expires_at',
'request_id', 'created_at')
def test_list_with_sorting(self, key):
fake_message = fake.Message()
mock_list = self.mock_object(
self.manager, '_list', mock.Mock(return_value=[fake_message]))
result = self.manager.list(sort_dir='asc', sort_key=key)
self.assertEqual([fake_message], result)
expected_path = (
messages.RESOURCES_PATH + '?sort_dir=asc&sort_key=' +
key)
mock_list.assert_called_once_with(
expected_path, messages.RESOURCES_NAME)
@ddt.data(
('name', 'invalid'),
('invalid', 'asc'),
)
@ddt.unpack
def test_list_with_invalid_sorting(self, sort_key, sort_dir):
self.assertRaises(
ValueError,
self.manager.list, sort_dir=sort_dir, sort_key=sort_key)
def test_delete(self):
mock_delete = self.mock_object(self.manager, '_delete')
mock_post = self.mock_object(self.manager.api.client, 'post')
self.manager.delete(fake.Message())
mock_delete.assert_called_once_with(
messages.RESOURCE_PATH % fake.Message.id)
self.assertFalse(mock_post.called)

View File

@ -30,6 +30,7 @@ from manilaclient import exceptions
from manilaclient import shell
from manilaclient.tests.unit import utils as test_utils
from manilaclient.tests.unit.v2 import fakes
from manilaclient.v2 import messages
from manilaclient.v2 import security_services
from manilaclient.v2 import share_instances
from manilaclient.v2 import share_networks
@ -2623,3 +2624,51 @@ class ShellTest(test_utils.TestCase):
self.assert_called_anytime(
'DELETE', '/share-servers/%s' % server.id,
clear_callstack=False)
@mock.patch.object(cliutils, 'print_list', mock.Mock())
def test_message_list(self):
self.run_command('message-list')
self.assert_called('GET', '/messages')
cliutils.print_list.assert_called_once_with(
mock.ANY, fields=['ID', 'Resource Type', 'Resource ID',
'Action ID', 'User Message', 'Detail ID',
'Created At'], sortby_index=None)
@mock.patch.object(cliutils, 'print_list', mock.Mock())
def test_message_list_select_column(self):
self.run_command('message-list --columns id,resource_type')
self.assert_called('GET', '/messages')
cliutils.print_list.assert_called_once_with(
mock.ANY, fields=['Id', 'Resource_Type'], sortby_index=None)
def test_message_list_with_filters(self):
self.run_command('message-list --limit 10 --offset 0')
self.assert_called(
'GET', '/messages?limit=10&offset=0')
def test_message_show(self):
self.run_command('message-show 1234')
self.assert_called('GET', '/messages/1234')
@ddt.data(('1234', ), ('1234', '5678'))
def test_message_delete(self, ids):
fake_messages = [
messages.Message('fake', {'id': mid}, True) for mid in ids
]
self.mock_object(
shell_v2, '_find_message',
mock.Mock(side_effect=fake_messages))
self.run_command('message-delete %s' % ' '.join(ids))
shell_v2._find_message.assert_has_calls([
mock.call(self.shell.cs, mid) for mid in ids
])
for fake_message in fake_messages:
self.assert_called_anytime(
'DELETE', '/messages/%s' % fake_message.id,
clear_callstack=False)

View File

@ -23,6 +23,7 @@ from manilaclient.common import httpclient
from manilaclient import exceptions
from manilaclient.v2 import availability_zones
from manilaclient.v2 import limits
from manilaclient.v2 import messages
from manilaclient.v2 import quota_classes
from manilaclient.v2 import quotas
from manilaclient.v2 import scheduler_stats
@ -212,6 +213,7 @@ class Client(object):
self.availability_zones = availability_zones.AvailabilityZoneManager(
self)
self.limits = limits.LimitsManager(self)
self.messages = messages.MessageManager(self)
self.services = services.ServiceManager(self)
self.security_services = security_services.SecurityServiceManager(self)
self.share_networks = share_networks.ShareNetworkManager(self)

View File

@ -0,0 +1,96 @@
# 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.
"""Asynchronous User Message interface."""
try:
from urllib import urlencode # noqa
except ImportError:
from urllib.parse import urlencode # noqa
from manilaclient import api_versions
from manilaclient import base
from manilaclient.common.apiclient import base as common_base
from manilaclient.common import constants
RESOURCES_PATH = '/messages'
RESOURCE_PATH = '/messages/%s'
RESOURCES_NAME = 'messages'
RESOURCE_NAME = 'message'
class Message(common_base.Resource):
NAME_ATTR = 'id'
def __repr__(self):
return "<Message: %s>" % self.id
def delete(self):
"""Delete this message."""
return self.manager.delete(self)
class MessageManager(base.ManagerWithFind):
"""Manage :class:`Message` resources."""
resource_class = Message
@api_versions.wraps('2.37')
def get(self, message_id):
"""Get a message.
:param message_id: The ID of the message to get.
:rtype: :class:`Message`
"""
return self._get(RESOURCE_PATH % message_id, RESOURCE_NAME)
@api_versions.wraps('2.37')
def list(self, search_opts=None, sort_key=None, sort_dir=None):
"""Lists all messages.
:param search_opts: Search options to filter out messages.
:rtype: list of :class:`Message`
"""
search_opts = search_opts or {}
if sort_key is not None:
if sort_key in constants.MESSAGE_SORT_KEY_VALUES:
search_opts['sort_key'] = sort_key
else:
raise ValueError(
'sort_key must be one of the following: %s.'
% ', '.join(constants.MESSAGE_SORT_KEY_VALUES))
if sort_dir is not None:
if sort_dir in constants.SORT_DIR_VALUES:
search_opts['sort_dir'] = sort_dir
else:
raise ValueError('sort_dir must be one of the following: %s.'
% ', '.join(constants.SORT_DIR_VALUES))
if search_opts:
query_string = urlencode(
sorted([(k, v) for (k, v) in list(search_opts.items()) if v]))
if query_string:
query_string = "?%s" % (query_string,)
else:
query_string = ''
path = RESOURCES_PATH + query_string
return self._list(path, RESOURCES_NAME)
@api_versions.wraps('2.37')
def delete(self, message):
"""Delete a message."""
loc = RESOURCE_PATH % common_base.getid(message)
return self._delete(loc)

View File

@ -269,6 +269,11 @@ def _find_share_server(cs, share_server):
return apiclient_utils.find_resource(cs.share_servers, share_server)
def _find_message(cs, message):
"""Get a message by ID."""
return apiclient_utils.find_resource(cs.messages, message)
def _translate_keys(collection, convert):
for item in collection:
keys = item.__dict__
@ -4650,3 +4655,170 @@ def do_share_replica_resync(cs, args):
"""
replica = _find_share_replica(cs, args.replica)
cs.share_replicas.resync(replica)
##############################################################################
#
# User Messages
#
##############################################################################
@api_versions.wraps("2.37")
@cliutils.arg(
'--resource_id',
'--resource-id',
'--resource',
metavar='<resource_id>',
default=None,
action='single_alias',
help='Filters results by a resource uuid. Default=None.')
@cliutils.arg(
'--resource_type',
'--resource-type',
metavar='<type>',
default=None,
action='single_alias',
help='Filters results by a resource type. Default=None. '
'Example: "manila message-list --resource_type share"')
@cliutils.arg(
'--action_id',
'--action-id',
'--action',
metavar='<id>',
default=None,
action='single_alias',
help='Filters results by action id. Default=None.')
@cliutils.arg(
'--detail_id',
'--detail-id',
'--detail',
metavar='<id>',
default=None,
action='single_alias',
help='Filters results by detail id. Default=None.')
@cliutils.arg(
'--request_id',
'--request-id',
'--request',
metavar='<request_id>',
default=None,
action='single_alias',
help='Filters results by request id. Default=None.')
@cliutils.arg(
'--level',
'--message_level',
'--message-level',
metavar='<level>',
default=None,
action='single_alias',
help='Filters results by the message level. Default=None. '
'Example: "manila message-list --level ERROR".')
@cliutils.arg(
'--limit',
metavar='<limit>',
type=int,
default=None,
help='Maximum number of messages to return. (Default=None)')
@cliutils.arg(
'--offset',
metavar="<offset>",
default=None,
help='Start position of message listing.')
@cliutils.arg(
'--sort-key', '--sort_key',
metavar='<sort_key>',
type=str,
default=None,
action='single_alias',
help='Key to be sorted, available keys are %(keys)s. Default=desc.' % {
'keys': constants.MESSAGE_SORT_KEY_VALUES})
@cliutils.arg(
'--sort-dir', '--sort_dir',
metavar='<sort_dir>',
type=str,
default=None,
action='single_alias',
help='Sort direction, available values are %(values)s. '
'OPTIONAL: Default=None.' % {'values': constants.SORT_DIR_VALUES})
@cliutils.arg(
'--columns',
metavar='<columns>',
type=str,
default=None,
help='Comma separated list of columns to be displayed '
'example --columns "resource_id,user_message".')
def do_message_list(cs, args):
"""Lists all messages."""
if args.columns is not None:
list_of_keys = _split_columns(columns=args.columns)
else:
list_of_keys = ['ID', 'Resource Type', 'Resource ID', 'Action ID',
'User Message', 'Detail ID', 'Created At']
search_opts = {
'offset': args.offset,
'limit': args.limit,
'request_id': args.request_id,
'resource_type': args.resource_type,
'resource_id': args.resource_id,
'action_id': args.action_id,
'detail_id': args.detail_id,
'message_level': args.level
}
messages = cs.messages.list(
search_opts=search_opts, sort_key=args.sort_key,
sort_dir=args.sort_dir)
cliutils.print_list(messages, fields=list_of_keys, sortby_index=None)
@cliutils.arg(
'message',
metavar='<message>',
help='ID of the message.')
@api_versions.wraps("2.37")
def do_message_show(cs, args):
"""Show details about a message."""
message = cs.messages.get(args.message)
_print_message(message)
@api_versions.wraps("2.37")
@cliutils.arg(
'message',
metavar='<message>',
nargs='+',
help='ID of the message(s).')
def do_message_delete(cs, args):
"""Remove one or more messages."""
failure_count = 0
for message in args.message:
try:
message_ref = _find_message(cs, message)
cs.messages.delete(message_ref)
except Exception as e:
failure_count += 1
print("Delete for message %s failed: %s" % (message, e),
file=sys.stderr)
if failure_count == len(args.message):
raise exceptions.CommandError("Unable to delete any of the specified "
"messages.")
def _print_message(message):
message_dict = {
'id': message.id,
'resource_type': message.resource_type,
'resource_id': message.resource_id,
'action_id': message.action_id,
'user_message': message.user_message,
'message_level': message.message_level,
'detail_id': message.detail_id,
'created_at': message.created_at,
'expires_at': message.expires_at,
'request_id': message.request_id,
}
cliutils.print_dict(message_dict)

View File

@ -0,0 +1,3 @@
---
features:
- Added new subcommands message-list, message-show and message-delete.