diff --git a/doc/source/cli/command-objects/volume-message.rst b/doc/source/cli/command-objects/volume-message.rst new file mode 100644 index 0000000000..5b1a8acef2 --- /dev/null +++ b/doc/source/cli/command-objects/volume-message.rst @@ -0,0 +1,8 @@ +============== +volume message +============== + +Block Storage v3 + +.. autoprogram-cliff:: openstack.volume.v3 + :command: volume message * diff --git a/doc/source/cli/commands.rst b/doc/source/cli/commands.rst index 00f6b23a77..cc95b3d121 100644 --- a/doc/source/cli/commands.rst +++ b/doc/source/cli/commands.rst @@ -160,6 +160,7 @@ referring to both Compute and Volume quotas. * ``volume backup record``: (**Volume**) volume record that can be imported or exported * ``volume backend``: (**Volume**) volume backend storage * ``volume host``: (**Volume**) the physical computer for volumes +* ``volume message``: (**Volume**) volume API internal messages detailing volume failure messages * ``volume qos``: (**Volume**) quality-of-service (QoS) specification for volumes * ``volume snapshot``: (**Volume**) a point-in-time copy of a volume * ``volume type``: (**Volume**) deployment-specific types of volumes available diff --git a/doc/source/cli/data/cinder.csv b/doc/source/cli/data/cinder.csv index dc43ab5b1a..f94552ea93 100644 --- a/doc/source/cli/data/cinder.csv +++ b/doc/source/cli/data/cinder.csv @@ -72,9 +72,9 @@ list,volume list,Lists all volumes. list-filters,,List enabled filters. (Supported by API versions 3.33 - 3.latest) manage,volume create --remote-source k=v,Manage an existing volume. manageable-list,,Lists all manageable volumes. (Supported by API versions 3.8 - 3.latest) -message-delete,,Removes one or more messages. (Supported by API versions 3.3 - 3.latest) -message-list,,Lists all messages. (Supported by API versions 3.3 - 3.latest) -message-show,,Shows message details. (Supported by API versions 3.3 - 3.latest) +message-delete,volume message delete,Removes one or more messages. (Supported by API versions 3.3 - 3.latest) +message-list,volume message list,Lists all messages. (Supported by API versions 3.3 - 3.latest) +message-show,volume message show,Shows message details. (Supported by API versions 3.3 - 3.latest) metadata,volume set --property k=v / volume unset --property k,Sets or deletes volume metadata. metadata-show,volume show,Shows volume metadata. metadata-update-all,volume set --property k=v,Updates volume metadata. diff --git a/openstackclient/tests/unit/volume/v3/fakes.py b/openstackclient/tests/unit/volume/v3/fakes.py index fb3b1b74e1..45cad8c14c 100644 --- a/openstackclient/tests/unit/volume/v3/fakes.py +++ b/openstackclient/tests/unit/volume/v3/fakes.py @@ -32,6 +32,8 @@ class FakeVolumeClient(object): self.attachments = mock.Mock() self.attachments.resource_class = fakes.FakeResource(None, {}) + self.messages = mock.Mock() + self.messages.resource_class = fakes.FakeResource(None, {}) self.volumes = mock.Mock() self.volumes.resource_class = fakes.FakeResource(None, {}) @@ -59,6 +61,72 @@ class TestVolume(utils.TestCommand): FakeVolume = volume_v2_fakes.FakeVolume +class FakeVolumeMessage: + """Fake one or more volume messages.""" + + @staticmethod + def create_one_volume_message(attrs=None): + """Create a fake message. + + :param attrs: A dictionary with all attributes of message + :return: A FakeResource object with id, name, status, etc. + """ + attrs = attrs or {} + + # Set default attribute + message_info = { + 'created_at': '2016-02-11T11:17:37.000000', + 'event_id': f'VOLUME_{random.randint(1, 999999):06d}', + 'guaranteed_until': '2016-02-11T11:17:37.000000', + 'id': uuid.uuid4().hex, + 'message_level': 'ERROR', + 'request_id': f'req-{uuid.uuid4().hex}', + 'resource_type': 'VOLUME', + 'resource_uuid': uuid.uuid4().hex, + 'user_message': f'message-{uuid.uuid4().hex}', + } + + # Overwrite default attributes if there are some attributes set + message_info.update(attrs) + + message = fakes.FakeResource( + None, + message_info, + loaded=True) + return message + + @staticmethod + def create_volume_messages(attrs=None, count=2): + """Create multiple fake messages. + + :param attrs: A dictionary with all attributes of message + :param count: The number of messages to be faked + :return: A list of FakeResource objects + """ + messages = [] + for n in range(0, count): + messages.append(FakeVolumeMessage.create_one_volume_message(attrs)) + + return messages + + @staticmethod + def get_volume_messages(messages=None, count=2): + """Get an iterable MagicMock object with a list of faked messages. + + If messages list is provided, then initialize the Mock object with the + list. Otherwise create one. + + :param messages: A list of FakeResource objects faking messages + :param count: The number of messages to be faked + :return An iterable Mock object with side_effect set to a list of faked + messages + """ + if messages is None: + messages = FakeVolumeMessage.create_messages(count) + + return mock.Mock(side_effect=messages) + + class FakeVolumeAttachment: """Fake one or more volume attachments.""" diff --git a/openstackclient/tests/unit/volume/v3/test_volume_message.py b/openstackclient/tests/unit/volume/v3/test_volume_message.py new file mode 100644 index 0000000000..68becf4418 --- /dev/null +++ b/openstackclient/tests/unit/volume/v3/test_volume_message.py @@ -0,0 +1,324 @@ +# 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 unittest.mock import call + +from cinderclient import api_versions +from osc_lib import exceptions + +from openstackclient.tests.unit.identity.v3 import fakes as identity_fakes +from openstackclient.tests.unit.volume.v3 import fakes as volume_fakes +from openstackclient.volume.v3 import volume_message + + +class TestVolumeMessage(volume_fakes.TestVolume): + + def setUp(self): + super().setUp() + + self.projects_mock = self.app.client_manager.identity.projects + self.projects_mock.reset_mock() + + self.volume_messages_mock = self.app.client_manager.volume.messages + self.volume_messages_mock.reset_mock() + + +class TestVolumeMessageDelete(TestVolumeMessage): + + fake_messages = volume_fakes.FakeVolumeMessage.create_volume_messages( + count=2) + + def setUp(self): + super().setUp() + + self.volume_messages_mock.get = \ + volume_fakes.FakeVolumeMessage.get_volume_messages( + self.fake_messages) + self.volume_messages_mock.delete.return_value = None + + # Get the command object to mock + self.cmd = volume_message.DeleteMessage(self.app, None) + + def test_message_delete(self): + self.app.client_manager.volume.api_version = \ + api_versions.APIVersion('3.3') + + arglist = [ + self.fake_messages[0].id, + ] + verifylist = [ + ('message_ids', [self.fake_messages[0].id]), + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + result = self.cmd.take_action(parsed_args) + + self.volume_messages_mock.delete.assert_called_with( + self.fake_messages[0].id) + self.assertIsNone(result) + + def test_message_delete_multiple_messages(self): + self.app.client_manager.volume.api_version = \ + api_versions.APIVersion('3.3') + + arglist = [ + self.fake_messages[0].id, + self.fake_messages[1].id, + ] + verifylist = [ + ('message_ids', arglist), + ] + + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + result = self.cmd.take_action(parsed_args) + + calls = [] + for m in self.fake_messages: + calls.append(call(m.id)) + self.volume_messages_mock.delete.assert_has_calls(calls) + self.assertIsNone(result) + + def test_message_delete_multiple_messages_with_exception(self): + self.app.client_manager.volume.api_version = \ + api_versions.APIVersion('3.3') + + arglist = [ + self.fake_messages[0].id, + 'invalid_message', + ] + verifylist = [ + ('message_ids', arglist), + ] + + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + self.volume_messages_mock.delete.side_effect = [ + self.fake_messages[0], exceptions.CommandError] + + exc = self.assertRaises( + exceptions.CommandError, + self.cmd.take_action, parsed_args) + self.assertEqual('Failed to delete 1 of 2 messages.', str(exc)) + + self.volume_messages_mock.delete.assert_any_call( + self.fake_messages[0].id) + self.volume_messages_mock.delete.assert_any_call('invalid_message') + + self.assertEqual(2, self.volume_messages_mock.delete.call_count) + + def test_message_delete_pre_v33(self): + self.app.client_manager.volume.api_version = \ + api_versions.APIVersion('3.2') + + arglist = [ + self.fake_messages[0].id, + ] + verifylist = [ + ('message_ids', [self.fake_messages[0].id]), + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + exc = self.assertRaises( + exceptions.CommandError, + self.cmd.take_action, + parsed_args) + self.assertIn( + '--os-volume-api-version 3.3 or greater is required', + str(exc)) + + +class TestVolumeMessageList(TestVolumeMessage): + + fake_project = identity_fakes.FakeProject.create_one_project() + fake_messages = volume_fakes.FakeVolumeMessage.create_volume_messages( + count=3) + + columns = ( + 'ID', + 'Event ID', + 'Resource Type', + 'Resource UUID', + 'Message Level', + 'User Message', + 'Request ID', + 'Created At', + 'Guaranteed Until', + ) + data = [] + for fake_message in fake_messages: + data.append(( + fake_message.id, + fake_message.event_id, + fake_message.resource_type, + fake_message.resource_uuid, + fake_message.message_level, + fake_message.user_message, + fake_message.request_id, + fake_message.created_at, + fake_message.guaranteed_until, + )) + + def setUp(self): + super().setUp() + + self.projects_mock.get.return_value = self.fake_project + self.volume_messages_mock.list.return_value = self.fake_messages + # Get the command to test + self.cmd = volume_message.ListMessages(self.app, None) + + def test_message_list(self): + self.app.client_manager.volume.api_version = \ + api_versions.APIVersion('3.3') + + arglist = [] + verifylist = [ + ('project', None), + ('marker', None), + ('limit', None), + ] + + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + columns, data = self.cmd.take_action(parsed_args) + + search_opts = { + 'project_id': None, + } + self.volume_messages_mock.list.assert_called_with( + search_opts=search_opts, + marker=None, + limit=None, + ) + self.assertEqual(self.columns, columns) + self.assertItemsEqual(self.data, list(data)) + + def test_message_list_with_options(self): + self.app.client_manager.volume.api_version = \ + api_versions.APIVersion('3.3') + + arglist = [ + '--project', self.fake_project.name, + '--marker', self.fake_messages[0].id, + '--limit', '3', + ] + verifylist = [ + ('project', self.fake_project.name), + ('marker', self.fake_messages[0].id), + ('limit', 3), + ] + + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + columns, data = self.cmd.take_action(parsed_args) + + search_opts = { + 'project_id': self.fake_project.id, + } + self.volume_messages_mock.list.assert_called_with( + search_opts=search_opts, + marker=self.fake_messages[0].id, + limit=3, + ) + self.assertEqual(self.columns, columns) + self.assertItemsEqual(self.data, list(data)) + + def test_message_list_pre_v33(self): + self.app.client_manager.volume.api_version = \ + api_versions.APIVersion('3.2') + + arglist = [] + verifylist = [ + ('project', None), + ('marker', None), + ('limit', None), + ] + + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + exc = self.assertRaises( + exceptions.CommandError, + self.cmd.take_action, + parsed_args) + self.assertIn( + '--os-volume-api-version 3.3 or greater is required', + str(exc)) + + +class TestVolumeMessageShow(TestVolumeMessage): + + fake_message = volume_fakes.FakeVolumeMessage.create_one_volume_message() + + columns = ( + 'created_at', + 'event_id', + 'guaranteed_until', + 'id', + 'message_level', + 'request_id', + 'resource_type', + 'resource_uuid', + 'user_message', + ) + data = ( + fake_message.created_at, + fake_message.event_id, + fake_message.guaranteed_until, + fake_message.id, + fake_message.message_level, + fake_message.request_id, + fake_message.resource_type, + fake_message.resource_uuid, + fake_message.user_message, + ) + + def setUp(self): + super().setUp() + + self.volume_messages_mock.get.return_value = self.fake_message + # Get the command object to test + self.cmd = volume_message.ShowMessage(self.app, None) + + def test_message_show(self): + self.app.client_manager.volume.api_version = \ + api_versions.APIVersion('3.3') + + arglist = [ + self.fake_message.id + ] + verifylist = [ + ('message_id', self.fake_message.id) + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + columns, data = self.cmd.take_action(parsed_args) + self.volume_messages_mock.get.assert_called_with(self.fake_message.id) + + self.assertEqual(self.columns, columns) + self.assertEqual(self.data, data) + + def test_message_show_pre_v33(self): + self.app.client_manager.volume.api_version = \ + api_versions.APIVersion('3.2') + + arglist = [ + self.fake_message.id + ] + verifylist = [ + ('message_id', self.fake_message.id) + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + exc = self.assertRaises( + exceptions.CommandError, + self.cmd.take_action, + parsed_args) + self.assertIn( + '--os-volume-api-version 3.3 or greater is required', + str(exc)) diff --git a/openstackclient/volume/v3/volume_message.py b/openstackclient/volume/v3/volume_message.py new file mode 100644 index 0000000000..4fe5ae92f6 --- /dev/null +++ b/openstackclient/volume/v3/volume_message.py @@ -0,0 +1,165 @@ +# +# 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. +# + +"""Volume V3 Messages implementations""" + +import logging as LOG + +from cinderclient import api_versions +from osc_lib.command import command +from osc_lib import exceptions +from osc_lib import utils + +from openstackclient.i18n import _ +from openstackclient.identity import common as identity_common + + +class DeleteMessage(command.Command): + _description = _('Delete a volume failure message') + + def get_parser(self, prog_name): + parser = super().get_parser(prog_name) + parser.add_argument( + 'message_ids', + metavar='', + nargs='+', + help=_('Message(s) to delete (ID)') + ) + + return parser + + def take_action(self, parsed_args): + volume_client = self.app.client_manager.volume + + if volume_client.api_version < api_versions.APIVersion('3.3'): + msg = _( + "--os-volume-api-version 3.3 or greater is required to " + "support the 'volume message delete' command" + ) + raise exceptions.CommandError(msg) + + errors = 0 + for message_id in parsed_args.message_ids: + try: + volume_client.messages.delete(message_id) + except Exception: + LOG.error(_('Failed to delete message: %s'), message_id) + errors += 1 + + if errors > 0: + total = len(parsed_args.message_ids) + msg = _('Failed to delete %(errors)s of %(total)s messages.') % { + 'errors': errors, 'total': total, + } + raise exceptions.CommandError(msg) + + +class ListMessages(command.Lister): + _description = _('List volume failure messages') + + def get_parser(self, prog_name): + parser = super().get_parser(prog_name) + + parser.add_argument( + '--project', + metavar='', + help=_('Filter results by project (name or ID) (admin only)'), + ) + identity_common.add_project_domain_option_to_parser(parser) + parser.add_argument( + '--marker', + metavar='', + help=_('The last message ID of the previous page'), + default=None, + ) + parser.add_argument( + '--limit', + type=int, + metavar='', + help=_('Maximum number of messages to display'), + default=None, + ) + + return parser + + def take_action(self, parsed_args): + volume_client = self.app.client_manager.volume + identity_client = self.app.client_manager.identity + + if volume_client.api_version < api_versions.APIVersion('3.3'): + msg = _( + "--os-volume-api-version 3.3 or greater is required to " + "support the 'volume message list' command" + ) + raise exceptions.CommandError(msg) + + column_headers = ( + 'ID', + 'Event ID', + 'Resource Type', + 'Resource UUID', + 'Message Level', + 'User Message', + 'Request ID', + 'Created At', + 'Guaranteed Until', + ) + + project_id = None + if parsed_args.project: + project_id = identity_common.find_project( + identity_client, + parsed_args.project, + parsed_args.project_domain).id + + search_opts = { + 'project_id': project_id, + } + data = volume_client.messages.list( + search_opts=search_opts, + marker=parsed_args.marker, + limit=parsed_args.limit) + + return ( + column_headers, + (utils.get_item_properties(s, column_headers) for s in data) + ) + + +class ShowMessage(command.ShowOne): + _description = _('Show a volume failure message') + + def get_parser(self, prog_name): + parser = super(ShowMessage, self).get_parser(prog_name) + parser.add_argument( + 'message_id', + metavar='', + help=_('Message to show (ID).') + ) + + return parser + + def take_action(self, parsed_args): + volume_client = self.app.client_manager.volume + + if volume_client.api_version < api_versions.APIVersion('3.3'): + msg = _( + "--os-volume-api-version 3.3 or greater is required to " + "support the 'volume message show' command" + ) + raise exceptions.CommandError(msg) + + message = volume_client.messages.get(parsed_args.message_id) + + return zip(*sorted(message._info.items())) diff --git a/releasenotes/notes/add-volume-message-commands-89a590a1549c333e.yaml b/releasenotes/notes/add-volume-message-commands-89a590a1549c333e.yaml new file mode 100644 index 0000000000..eba53524e7 --- /dev/null +++ b/releasenotes/notes/add-volume-message-commands-89a590a1549c333e.yaml @@ -0,0 +1,6 @@ +--- +features: + - | + Add ``volume message list``, ``volume message get`` and + ``volume message delete`` commands, to list, get and delete volume + failure messages, respectively. diff --git a/setup.cfg b/setup.cfg index 792e5a8584..d593762ac9 100644 --- a/setup.cfg +++ b/setup.cfg @@ -716,6 +716,10 @@ openstack.volume.v3 = volume_host_set = openstackclient.volume.v2.volume_host:SetVolumeHost + volume_message_delete = openstackclient.volume.v3.volume_message:DeleteMessage + volume_message_list = openstackclient.volume.v3.volume_message:ListMessages + volume_message_show = openstackclient.volume.v3.volume_message:ShowMessage + volume_snapshot_create = openstackclient.volume.v2.volume_snapshot:CreateVolumeSnapshot volume_snapshot_delete = openstackclient.volume.v2.volume_snapshot:DeleteVolumeSnapshot volume_snapshot_list = openstackclient.volume.v2.volume_snapshot:ListVolumeSnapshot