diff --git a/doc/source/cli/command-objects/volume-attachment.rst b/doc/source/cli/command-objects/volume-attachment.rst new file mode 100644 index 0000000000..5622444638 --- /dev/null +++ b/doc/source/cli/command-objects/volume-attachment.rst @@ -0,0 +1,8 @@ +================= +volume attachment +================= + +Block Storage v3 + +.. autoprogram-cliff:: openstack.volume.v3 + :command: volume attachment * diff --git a/doc/source/cli/commands.rst b/doc/source/cli/commands.rst index 94a0b5a638..00f6b23a77 100644 --- a/doc/source/cli/commands.rst +++ b/doc/source/cli/commands.rst @@ -153,11 +153,12 @@ referring to both Compute and Volume quotas. * ``user``: (**Identity**) individual cloud resources users * ``user role``: (**Identity**) roles assigned to a user * ``volume``: (**Volume**) block volumes +* ``volume attachment``: (**Volume**) an attachment of a volumes to a server * ``volume backup``: (**Volume**) backup for volumes -* ``volume backend capability``: (**volume**) volume backend storage capabilities -* ``volume backend pool``: (**volume**) volume backend storage pools +* ``volume backend capability``: (**Volume**) volume backend storage capabilities +* ``volume backend pool``: (**Volume**) volume backend storage pools * ``volume backup record``: (**Volume**) volume record that can be imported or exported -* ``volume backend``: (**volume**) volume backend storage +* ``volume backend``: (**Volume**) volume backend storage * ``volume host``: (**Volume**) the physical computer for volumes * ``volume qos``: (**Volume**) quality-of-service (QoS) specification for volumes * ``volume snapshot``: (**Volume**) a point-in-time copy of a volume diff --git a/doc/source/cli/data/cinder.csv b/doc/source/cli/data/cinder.csv index 27141494a2..dc43ab5b1a 100644 --- a/doc/source/cli/data/cinder.csv +++ b/doc/source/cli/data/cinder.csv @@ -1,12 +1,12 @@ absolute-limits,limits show --absolute,Lists absolute limits for a user. api-version,WONTFIX,Display the server API version information. availability-zone-list,availability zone list --volume,Lists all availability zones. -attachment-complete,,Complete an attachment for a cinder volume. (Supported by API versions 3.44 - 3.latest) -attachment-create,,Create an attachment for a cinder volume. (Supported by API versions 3.27 - 3.latest) -attachment-delete,,Delete an attachment for a cinder volume. (Supported by API versions 3.27 - 3.latest) -attachment-list,,Lists all attachments. (Supported by API versions 3.27 - 3.latest) -attachment-show,,Show detailed information for attachment. (Supported by API versions 3.27 - 3.latest) -attachment-update,,Update an attachment for a cinder volume. (Supported by API versions 3.27 - 3.latest) +attachment-complete,volume attachment complete,Complete an attachment for a cinder volume. (Supported by API versions 3.44 - 3.latest) +attachment-create,volume attachment create,Create an attachment for a cinder volume. (Supported by API versions 3.27 - 3.latest) +attachment-delete,volume attachment delete,Delete an attachment for a cinder volume. (Supported by API versions 3.27 - 3.latest) +attachment-list,volume attachment list,Lists all attachments. (Supported by API versions 3.27 - 3.latest) +attachment-show,volume attachment show,Show detailed information for attachment. (Supported by API versions 3.27 - 3.latest) +attachment-update,volume attachment update,Update an attachment for a cinder volume. (Supported by API versions 3.27 - 3.latest) backup-create,volume backup create,Creates a volume backup. backup-delete,volume backup delete,Removes a backup. backup-export,volume backup record export,Export backup metadata record. diff --git a/openstackclient/tests/unit/volume/v3/fakes.py b/openstackclient/tests/unit/volume/v3/fakes.py new file mode 100644 index 0000000000..fb3b1b74e1 --- /dev/null +++ b/openstackclient/tests/unit/volume/v3/fakes.py @@ -0,0 +1,155 @@ +# 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 random +from unittest import mock +import uuid + +from cinderclient import api_versions + +from openstackclient.tests.unit.compute.v2 import fakes as compute_fakes +from openstackclient.tests.unit import fakes +from openstackclient.tests.unit.identity.v3 import fakes as identity_fakes +from openstackclient.tests.unit import utils +from openstackclient.tests.unit.volume.v2 import fakes as volume_v2_fakes + + +class FakeVolumeClient(object): + + def __init__(self, **kwargs): + self.auth_token = kwargs['token'] + self.management_url = kwargs['endpoint'] + self.api_version = api_versions.APIVersion('3.0') + + self.attachments = mock.Mock() + self.attachments.resource_class = fakes.FakeResource(None, {}) + self.volumes = mock.Mock() + self.volumes.resource_class = fakes.FakeResource(None, {}) + + +class TestVolume(utils.TestCommand): + + def setUp(self): + super().setUp() + + self.app.client_manager.volume = FakeVolumeClient( + endpoint=fakes.AUTH_URL, + token=fakes.AUTH_TOKEN + ) + self.app.client_manager.identity = identity_fakes.FakeIdentityv3Client( + endpoint=fakes.AUTH_URL, + token=fakes.AUTH_TOKEN + ) + self.app.client_manager.compute = compute_fakes.FakeComputev2Client( + endpoint=fakes.AUTH_URL, + token=fakes.AUTH_TOKEN, + ) + + +# TODO(stephenfin): Check if the responses are actually the same +FakeVolume = volume_v2_fakes.FakeVolume + + +class FakeVolumeAttachment: + """Fake one or more volume attachments.""" + + @staticmethod + def create_one_volume_attachment(attrs=None): + """Create a fake volume attachment. + + :param attrs: A dictionary with all attributes of volume attachment + :return: A FakeResource object with id, status, etc. + """ + attrs = attrs or {} + + attachment_id = uuid.uuid4().hex + volume_id = attrs.pop('volume_id', None) or uuid.uuid4().hex + server_id = attrs.pop('instance', None) or uuid.uuid4().hex + + # Set default attribute + attachment_info = { + 'id': attachment_id, + 'volume_id': volume_id, + 'instance': server_id, + 'status': random.choice([ + 'attached', + 'attaching', + 'detached', + 'reserved', + 'error_attaching', + 'error_detaching', + 'deleted', + ]), + 'attach_mode': random.choice(['ro', 'rw']), + 'attached_at': '2015-09-16T09:28:52.000000', + 'detached_at': None, + 'connection_info': { + 'access_mode': 'rw', + 'attachment_id': attachment_id, + 'auth_method': 'CHAP', + 'auth_password': 'AcUZ8PpxLHwzypMC', + 'auth_username': '7j3EZQWT3rbE6pcSGKvK', + 'cacheable': False, + 'driver_volume_type': 'iscsi', + 'encrypted': False, + 'qos_specs': None, + 'target_discovered': False, + 'target_iqn': + f'iqn.2010-10.org.openstack:volume-{attachment_id}', + 'target_lun': '1', + 'target_portal': '192.168.122.170:3260', + 'volume_id': volume_id, + }, + } + + # Overwrite default attributes if there are some attributes set + attachment_info.update(attrs) + + attachment = fakes.FakeResource( + None, + attachment_info, + loaded=True) + return attachment + + @staticmethod + def create_volume_attachments(attrs=None, count=2): + """Create multiple fake volume attachments. + + :param attrs: A dictionary with all attributes of volume attachment + :param count: The number of volume attachments to be faked + :return: A list of FakeResource objects + """ + attachments = [] + + for n in range(0, count): + attachments.append( + FakeVolumeAttachment.create_one_volume_attachment(attrs)) + + return attachments + + @staticmethod + def get_volume_attachments(attachments=None, count=2): + """Get an iterable MagicMock object with a list of faked volumes. + + If attachments list is provided, then initialize the Mock object with + the list. Otherwise create one. + + :param attachments: A list of FakeResource objects faking volume + attachments + :param count: The number of volume attachments to be faked + :return An iterable Mock object with side_effect set to a list of faked + volume attachments + """ + if attachments is None: + attachments = FakeVolumeAttachment.create_volume_attachments(count) + + return mock.Mock(side_effect=attachments) diff --git a/openstackclient/tests/unit/volume/v3/test_volume_attachment.py b/openstackclient/tests/unit/volume/v3/test_volume_attachment.py new file mode 100644 index 0000000000..09f698e7fd --- /dev/null +++ b/openstackclient/tests/unit/volume/v3/test_volume_attachment.py @@ -0,0 +1,560 @@ +# 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 cinderclient import api_versions +from osc_lib.cli import format_columns +from osc_lib import exceptions + +from openstackclient.tests.unit.compute.v2 import fakes as compute_fakes +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_attachment + + +class TestVolumeAttachment(volume_fakes.TestVolume): + + def setUp(self): + super().setUp() + + self.volumes_mock = self.app.client_manager.volume.volumes + self.volumes_mock.reset_mock() + + self.volume_attachments_mock = \ + self.app.client_manager.volume.attachments + self.volume_attachments_mock.reset_mock() + + self.projects_mock = self.app.client_manager.identity.projects + self.projects_mock.reset_mock() + + self.servers_mock = self.app.client_manager.compute.servers + self.servers_mock.reset_mock() + + +class TestVolumeAttachmentCreate(TestVolumeAttachment): + + volume = volume_fakes.FakeVolume.create_one_volume() + server = compute_fakes.FakeServer.create_one_server() + volume_attachment = \ + volume_fakes.FakeVolumeAttachment.create_one_volume_attachment( + attrs={'instance': server.id, 'volume_id': volume.id}) + + columns = ( + 'ID', + 'Volume ID', + 'Instance ID', + 'Status', + 'Attach Mode', + 'Attached At', + 'Detached At', + 'Properties', + ) + data = ( + volume_attachment.id, + volume_attachment.volume_id, + volume_attachment.instance, + volume_attachment.status, + volume_attachment.attach_mode, + volume_attachment.attached_at, + volume_attachment.detached_at, + format_columns.DictColumn(volume_attachment.connection_info), + ) + + def setUp(self): + super().setUp() + + self.volumes_mock.get.return_value = self.volume + self.servers_mock.get.return_value = self.server + self.volume_attachments_mock.create.return_value = \ + self.volume_attachment + + self.cmd = volume_attachment.CreateVolumeAttachment(self.app, None) + + def test_volume_attachment_create(self): + self.app.client_manager.volume.api_version = \ + api_versions.APIVersion('3.27') + + arglist = [ + self.volume.id, + self.server.id, + ] + verifylist = [ + ('volume', self.volume.id), + ('server', self.server.id), + ('connect', False), + ('initiator', None), + ('ip', None), + ('host', None), + ('platform', None), + ('os_type', None), + ('multipath', False), + ('mountpoint', None), + ('mode', None), + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + columns, data = self.cmd.take_action(parsed_args) + + self.volumes_mock.get.assert_called_once_with(self.volume.id) + self.servers_mock.get.assert_called_once_with(self.server.id) + self.volume_attachments_mock.create.assert_called_once_with( + self.volume.id, {}, self.server.id, None, + ) + self.assertEqual(self.columns, columns) + self.assertCountEqual(self.data, data) + + def test_volume_attachment_create_with_connect(self): + self.app.client_manager.volume.api_version = \ + api_versions.APIVersion('3.54') + + arglist = [ + self.volume.id, + self.server.id, + '--connect', + '--initiator', 'iqn.1993-08.org.debian:01:cad181614cec', + '--ip', '192.168.1.20', + '--host', 'my-host', + '--platform', 'x86_64', + '--os-type', 'linux2', + '--multipath', + '--mountpoint', '/dev/vdb', + '--mode', 'null', + ] + verifylist = [ + ('volume', self.volume.id), + ('server', self.server.id), + ('connect', True), + ('initiator', 'iqn.1993-08.org.debian:01:cad181614cec'), + ('ip', '192.168.1.20'), + ('host', 'my-host'), + ('platform', 'x86_64'), + ('os_type', 'linux2'), + ('multipath', True), + ('mountpoint', '/dev/vdb'), + ('mode', 'null'), + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + columns, data = self.cmd.take_action(parsed_args) + + connect_info = dict([ + ('initiator', 'iqn.1993-08.org.debian:01:cad181614cec'), + ('ip', '192.168.1.20'), + ('host', 'my-host'), + ('platform', 'x86_64'), + ('os_type', 'linux2'), + ('multipath', True), + ('mountpoint', '/dev/vdb'), + ]) + + self.volumes_mock.get.assert_called_once_with(self.volume.id) + self.servers_mock.get.assert_called_once_with(self.server.id) + self.volume_attachments_mock.create.assert_called_once_with( + self.volume.id, connect_info, self.server.id, 'null', + ) + self.assertEqual(self.columns, columns) + self.assertCountEqual(self.data, data) + + def test_volume_attachment_create_pre_v327(self): + self.app.client_manager.volume.api_version = \ + api_versions.APIVersion('3.26') + + arglist = [ + self.volume.id, + self.server.id, + ] + verifylist = [ + ('volume', self.volume.id), + ('server', self.server.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.27 or greater is required', + str(exc)) + + def test_volume_attachment_create_with_mode_pre_v354(self): + self.app.client_manager.volume.api_version = \ + api_versions.APIVersion('3.53') + + arglist = [ + self.volume.id, + self.server.id, + '--mode', 'rw', + ] + verifylist = [ + ('volume', self.volume.id), + ('server', self.server.id), + ('mode', 'rw'), + ] + 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.54 or greater is required', + str(exc)) + + def test_volume_attachment_create_with_connect_missing_arg(self): + self.app.client_manager.volume.api_version = \ + api_versions.APIVersion('3.54') + + arglist = [ + self.volume.id, + self.server.id, + '--initiator', 'iqn.1993-08.org.debian:01:cad181614cec', + ] + verifylist = [ + ('volume', self.volume.id), + ('server', self.server.id), + ('connect', False), + ('initiator', 'iqn.1993-08.org.debian:01:cad181614cec'), + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + exc = self.assertRaises( + exceptions.CommandError, + self.cmd.take_action, + parsed_args) + self.assertIn( + 'You must specify the --connect option for any', + str(exc)) + + +class TestVolumeAttachmentDelete(TestVolumeAttachment): + + volume_attachment = \ + volume_fakes.FakeVolumeAttachment.create_one_volume_attachment() + + def setUp(self): + super().setUp() + + self.volume_attachments_mock.delete.return_value = None + + self.cmd = volume_attachment.DeleteVolumeAttachment(self.app, None) + + def test_volume_attachment_delete(self): + self.app.client_manager.volume.api_version = \ + api_versions.APIVersion('3.27') + + arglist = [ + self.volume_attachment.id, + ] + verifylist = [ + ('attachment', self.volume_attachment.id), + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + result = self.cmd.take_action(parsed_args) + + self.volume_attachments_mock.delete.assert_called_once_with( + self.volume_attachment.id, + ) + self.assertIsNone(result) + + def test_volume_attachment_delete_pre_v327(self): + self.app.client_manager.volume.api_version = \ + api_versions.APIVersion('3.26') + + arglist = [ + self.volume_attachment.id, + ] + verifylist = [ + ('attachment', self.volume_attachment.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.27 or greater is required', + str(exc)) + + +class TestVolumeAttachmentSet(TestVolumeAttachment): + + volume_attachment = \ + volume_fakes.FakeVolumeAttachment.create_one_volume_attachment() + + columns = ( + 'ID', + 'Volume ID', + 'Instance ID', + 'Status', + 'Attach Mode', + 'Attached At', + 'Detached At', + 'Properties', + ) + data = ( + volume_attachment.id, + volume_attachment.volume_id, + volume_attachment.instance, + volume_attachment.status, + volume_attachment.attach_mode, + volume_attachment.attached_at, + volume_attachment.detached_at, + format_columns.DictColumn(volume_attachment.connection_info), + ) + + def setUp(self): + super().setUp() + + self.volume_attachments_mock.update.return_value = \ + self.volume_attachment + + self.cmd = volume_attachment.SetVolumeAttachment(self.app, None) + + def test_volume_attachment_set(self): + self.app.client_manager.volume.api_version = \ + api_versions.APIVersion('3.27') + + arglist = [ + self.volume_attachment.id, + '--initiator', 'iqn.1993-08.org.debian:01:cad181614cec', + '--ip', '192.168.1.20', + '--host', 'my-host', + '--platform', 'x86_64', + '--os-type', 'linux2', + '--multipath', + '--mountpoint', '/dev/vdb', + ] + verifylist = [ + ('attachment', self.volume_attachment.id), + ('initiator', 'iqn.1993-08.org.debian:01:cad181614cec'), + ('ip', '192.168.1.20'), + ('host', 'my-host'), + ('platform', 'x86_64'), + ('os_type', 'linux2'), + ('multipath', True), + ('mountpoint', '/dev/vdb'), + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + columns, data = self.cmd.take_action(parsed_args) + + connect_info = dict([ + ('initiator', 'iqn.1993-08.org.debian:01:cad181614cec'), + ('ip', '192.168.1.20'), + ('host', 'my-host'), + ('platform', 'x86_64'), + ('os_type', 'linux2'), + ('multipath', True), + ('mountpoint', '/dev/vdb'), + ]) + + self.volume_attachments_mock.update.assert_called_once_with( + self.volume_attachment.id, connect_info, + ) + self.assertEqual(self.columns, columns) + self.assertCountEqual(self.data, data) + + def test_volume_attachment_set_pre_v327(self): + self.app.client_manager.volume.api_version = \ + api_versions.APIVersion('3.26') + + arglist = [ + self.volume_attachment.id, + '--initiator', 'iqn.1993-08.org.debian:01:cad181614cec', + ] + verifylist = [ + ('attachment', self.volume_attachment.id), + ('initiator', 'iqn.1993-08.org.debian:01:cad181614cec'), + ] + 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.27 or greater is required', + str(exc)) + + +class TestVolumeAttachmentComplete(TestVolumeAttachment): + + volume_attachment = \ + volume_fakes.FakeVolumeAttachment.create_one_volume_attachment() + + def setUp(self): + super().setUp() + + self.volume_attachments_mock.complete.return_value = None + + self.cmd = volume_attachment.CompleteVolumeAttachment(self.app, None) + + def test_volume_attachment_complete(self): + self.app.client_manager.volume.api_version = \ + api_versions.APIVersion('3.44') + + arglist = [ + self.volume_attachment.id, + ] + verifylist = [ + ('attachment', self.volume_attachment.id), + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + result = self.cmd.take_action(parsed_args) + + self.volume_attachments_mock.complete.assert_called_once_with( + self.volume_attachment.id, + ) + self.assertIsNone(result) + + def test_volume_attachment_complete_pre_v344(self): + self.app.client_manager.volume.api_version = \ + api_versions.APIVersion('3.43') + + arglist = [ + self.volume_attachment.id, + ] + verifylist = [ + ('attachment', self.volume_attachment.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.44 or greater is required', + str(exc)) + + +class TestVolumeAttachmentList(TestVolumeAttachment): + + project = identity_fakes.FakeProject.create_one_project() + volume_attachments = \ + volume_fakes.FakeVolumeAttachment.create_volume_attachments() + + columns = ( + 'ID', + 'Volume ID', + 'Server ID', + 'Status', + ) + data = [ + ( + volume_attachment.id, + volume_attachment.volume_id, + volume_attachment.instance, + volume_attachment.status, + ) for volume_attachment in volume_attachments + ] + + def setUp(self): + super().setUp() + + self.projects_mock.get.return_value = self.project + self.volume_attachments_mock.list.return_value = \ + self.volume_attachments + + self.cmd = volume_attachment.ListVolumeAttachment(self.app, None) + + def test_volume_attachment_list(self): + self.app.client_manager.volume.api_version = \ + api_versions.APIVersion('3.27') + + arglist = [] + verifylist = [ + ('project', None), + ('all_projects', False), + ('volume_id', None), + ('status', None), + ('marker', None), + ('limit', None), + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + columns, data = self.cmd.take_action(parsed_args) + + self.volume_attachments_mock.list.assert_called_once_with( + search_opts={ + 'all_tenants': False, + 'project_id': None, + 'status': None, + 'volume_id': None, + }, + marker=None, + limit=None, + ) + self.assertEqual(self.columns, columns) + self.assertCountEqual(tuple(self.data), data) + + def test_volume_attachment_list_with_options(self): + self.app.client_manager.volume.api_version = \ + api_versions.APIVersion('3.27') + + arglist = [ + '--project', self.project.name, + '--volume-id', 'volume-id', + '--status', 'attached', + '--marker', 'volume-attachment-id', + '--limit', '2', + ] + verifylist = [ + ('project', self.project.name), + ('all_projects', False), + ('volume_id', 'volume-id'), + ('status', 'attached'), + ('marker', 'volume-attachment-id'), + ('limit', 2), + ] + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + columns, data = self.cmd.take_action(parsed_args) + + self.volume_attachments_mock.list.assert_called_once_with( + search_opts={ + 'all_tenants': True, + 'project_id': self.project.id, + 'status': 'attached', + 'volume_id': 'volume-id', + }, + marker='volume-attachment-id', + limit=2, + ) + self.assertEqual(self.columns, columns) + self.assertCountEqual(tuple(self.data), data) + + def test_volume_attachment_list_pre_v327(self): + self.app.client_manager.volume.api_version = \ + api_versions.APIVersion('3.26') + + arglist = [] + verifylist = [ + ('project', None), + ('all_projects', False), + ('volume_id', None), + ('status', 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.27 or greater is required', + str(exc)) diff --git a/openstackclient/volume/v3/volume_attachment.py b/openstackclient/volume/v3/volume_attachment.py new file mode 100644 index 0000000000..39a9c37fdb --- /dev/null +++ b/openstackclient/volume/v3/volume_attachment.py @@ -0,0 +1,511 @@ +# 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 logging + +from cinderclient import api_versions +from osc_lib.cli import format_columns +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 + +LOG = logging.getLogger(__name__) + +_FILTER_DEPRECATED = _( + "This option is deprecated. Consider using the '--filters' option which " + "was introduced in microversion 3.33 instead." +) + + +def _format_attachment(attachment): + columns = ( + 'id', + 'volume_id', + 'instance', + 'status', + 'attach_mode', + 'attached_at', + 'detached_at', + 'connection_info', + ) + column_headers = ( + 'ID', + 'Volume ID', + 'Instance ID', + 'Status', + 'Attach Mode', + 'Attached At', + 'Detached At', + 'Properties', + ) + + # TODO(stephenfin): Improve output with the nested connection_info + # field - cinderclient printed two things but that's equally ugly + return ( + column_headers, + utils.get_item_properties( + attachment, + columns, + formatters={ + 'connection_info': format_columns.DictColumn, + }, + ), + ) + + +class CreateVolumeAttachment(command.ShowOne): + """Create an attachment for a volume. + + This command will only create a volume attachment in the Volume service. It + will not invoke the necessary Compute service actions to actually attach + the volume to the server at the hypervisor level. As a result, it should + typically only be used for troubleshooting issues with an existing server + in combination with other tooling. For all other use cases, the 'server + volume add' command should be preferred. + """ + + def get_parser(self, prog_name): + parser = super().get_parser(prog_name) + parser.add_argument( + 'volume', + metavar='<volume>', + help=_('Name or ID of volume to attach to server.'), + ) + parser.add_argument( + 'server', + metavar='<server>', + help=_('Name or ID of server to attach volume to.'), + ) + parser.add_argument( + '--connect', + action='store_true', + dest='connect', + default=False, + help=_('Make an active connection using provided connector info'), + ) + parser.add_argument( + '--no-connect', + action='store_false', + dest='connect', + help=_( + 'Do not make an active connection using provided connector ' + 'info' + ), + ) + parser.add_argument( + '--initiator', + metavar='<initiator>', + help=_('IQN of the initiator attaching to'), + ) + parser.add_argument( + '--ip', + metavar='<ip>', + help=_('IP of the system attaching to'), + ) + parser.add_argument( + '--host', + metavar='<host>', + help=_('Name of the host attaching to'), + ) + parser.add_argument( + '--platform', + metavar='<platform>', + help=_('Platform type'), + ) + parser.add_argument( + '--os-type', + metavar='<ostype>', + help=_('OS type'), + ) + parser.add_argument( + '--multipath', + action='store_true', + dest='multipath', + default=False, + help=_('Use multipath'), + ) + parser.add_argument( + '--no-multipath', + action='store_false', + dest='multipath', + help=_('Use multipath'), + ) + parser.add_argument( + '--mountpoint', + metavar='<mountpoint>', + help=_('Mountpoint volume will be attached at'), + ) + parser.add_argument( + '--mode', + metavar='<mode>', + help=_( + 'Mode of volume attachment, rw, ro and null, where null ' + 'indicates we want to honor any existing admin-metadata ' + 'settings ' + '(supported by --os-volume-api-version 3.54 or later)' + ), + ) + return parser + + def take_action(self, parsed_args): + volume_client = self.app.client_manager.volume + compute_client = self.app.client_manager.compute + + if volume_client.api_version < api_versions.APIVersion('3.27'): + msg = _( + "--os-volume-api-version 3.27 or greater is required to " + "support the 'volume attachment create' command" + ) + raise exceptions.CommandError(msg) + + if parsed_args.mode: + if volume_client.api_version < api_versions.APIVersion('3.54'): + msg = _( + "--os-volume-api-version 3.54 or greater is required to " + "support the '--mode' option" + ) + raise exceptions.CommandError(msg) + + connector = {} + if parsed_args.connect: + connector = { + 'initiator': parsed_args.initiator, + 'ip': parsed_args.ip, + 'platform': parsed_args.platform, + 'host': parsed_args.host, + 'os_type': parsed_args.os_type, + 'multipath': parsed_args.multipath, + 'mountpoint': parsed_args.mountpoint, + } + else: + if any({ + parsed_args.initiator, + parsed_args.ip, + parsed_args.platform, + parsed_args.host, + parsed_args.host, + parsed_args.multipath, + parsed_args.mountpoint, + }): + msg = _( + 'You must specify the --connect option for any of the ' + 'connection-specific options such as --initiator to be ' + 'valid' + ) + raise exceptions.CommandError(msg) + + volume = utils.find_resource( + volume_client.volumes, + parsed_args.volume, + ) + server = utils.find_resource( + compute_client.servers, + parsed_args.server, + ) + + attachment = volume_client.attachments.create( + volume.id, connector, server.id, parsed_args.mode) + + return _format_attachment(attachment) + + +class DeleteVolumeAttachment(command.Command): + """Delete an attachment for a volume. + + Similarly to the 'volume attachment create' command, this command will only + delete the volume attachment record in the Volume service. It will not + invoke the necessary Compute service actions to actually attach the volume + to the server at the hypervisor level. As a result, it should typically + only be used for troubleshooting issues with an existing server in + combination with other tooling. For all other use cases, the 'server volume + remove' command should be preferred. + """ + + def get_parser(self, prog_name): + parser = super().get_parser(prog_name) + parser.add_argument( + 'attachment', + metavar='<attachment>', + help=_('ID of volume attachment to delete'), + ) + return parser + + def take_action(self, parsed_args): + volume_client = self.app.client_manager.volume + + if volume_client.api_version < api_versions.APIVersion('3.27'): + msg = _( + "--os-volume-api-version 3.27 or greater is required to " + "support the 'volume attachment delete' command" + ) + raise exceptions.CommandError(msg) + + volume_client.attachments.delete(parsed_args.attachment) + + +class SetVolumeAttachment(command.ShowOne): + """Update an attachment for a volume. + + This call is designed to be more of an volume attachment completion than + anything else. It expects the value of a connector object to notify the + driver that the volume is going to be connected and where it's being + connected to. + """ + + def get_parser(self, prog_name): + parser = super().get_parser(prog_name) + parser.add_argument( + 'attachment', + metavar='<attachment>', + help=_('ID of volume attachment.'), + ) + parser.add_argument( + '--initiator', + metavar='<initiator>', + help=_('IQN of the initiator attaching to'), + ) + parser.add_argument( + '--ip', + metavar='<ip>', + help=_('IP of the system attaching to'), + ) + parser.add_argument( + '--host', + metavar='<host>', + help=_('Name of the host attaching to'), + ) + parser.add_argument( + '--platform', + metavar='<platform>', + help=_('Platform type'), + ) + parser.add_argument( + '--os-type', + metavar='<ostype>', + help=_('OS type'), + ) + parser.add_argument( + '--multipath', + action='store_true', + dest='multipath', + default=False, + help=_('Use multipath'), + ) + parser.add_argument( + '--no-multipath', + action='store_false', + dest='multipath', + help=_('Use multipath'), + ) + parser.add_argument( + '--mountpoint', + metavar='<mountpoint>', + help=_('Mountpoint volume will be attached at'), + ) + return parser + + def take_action(self, parsed_args): + volume_client = self.app.client_manager.volume + + if volume_client.api_version < api_versions.APIVersion('3.27'): + msg = _( + "--os-volume-api-version 3.27 or greater is required to " + "support the 'volume attachment set' command" + ) + raise exceptions.CommandError(msg) + + connector = { + 'initiator': parsed_args.initiator, + 'ip': parsed_args.ip, + 'platform': parsed_args.platform, + 'host': parsed_args.host, + 'os_type': parsed_args.os_type, + 'multipath': parsed_args.multipath, + 'mountpoint': parsed_args.mountpoint, + } + + attachment = volume_client.attachments.update( + parsed_args.attachment, connector) + + return _format_attachment(attachment) + + +class CompleteVolumeAttachment(command.Command): + """Complete an attachment for a volume.""" + + def get_parser(self, prog_name): + parser = super().get_parser(prog_name) + parser.add_argument( + 'attachment', + metavar='<attachment>', + help=_('ID of volume attachment to mark as completed'), + ) + return parser + + def take_action(self, parsed_args): + volume_client = self.app.client_manager.volume + + if volume_client.api_version < api_versions.APIVersion('3.44'): + msg = _( + "--os-volume-api-version 3.44 or greater is required to " + "support the 'volume attachment complete' command" + ) + raise exceptions.CommandError(msg) + + volume_client.attachments.complete(parsed_args.attachment) + + +class ListVolumeAttachment(command.Lister): + """Lists all volume attachments.""" + + def get_parser(self, prog_name): + parser = super().get_parser(prog_name) + parser.add_argument( + '--project', + dest='project', + metavar='<project>', + help=_('Filter results by project (name or ID) (admin only)'), + ) + identity_common.add_project_domain_option_to_parser(parser) + parser.add_argument( + '--all-projects', + dest='all_projects', + action='store_true', + default=utils.env('ALL_PROJECTS', default=False), + help=_('Shows details for all projects (admin only).'), + ) + parser.add_argument( + '--volume-id', + metavar='<volume-id>', + default=None, + help=_('Filters results by a volume ID. ') + _FILTER_DEPRECATED, + ) + parser.add_argument( + '--status', + metavar='<status>', + help=_('Filters results by a status. ') + _FILTER_DEPRECATED, + ) + parser.add_argument( + '--marker', + metavar='<marker>', + help=_( + 'Begin returning volume attachments that appear later in ' + 'volume attachment list than that represented by this ID.' + ), + ) + parser.add_argument( + '--limit', + type=int, + metavar='<limit>', + help=_('Maximum number of volume attachments to return.'), + ) + # TODO(stephenfin): Add once we have an equivalent command for + # 'cinder list-filters' + # parser.add_argument( + # '--filter', + # metavar='<key=value>', + # action=parseractions.KeyValueAction, + # dest='filters', + # help=_( + # "Filter key and value pairs. Use 'foo' to " + # "check enabled filters from server. Use 'key~=value' for " + # "inexact filtering if the key supports " + # "(supported by --os-volume-api-version 3.33 or above)" + # ), + # ) + 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.27'): + msg = _( + "--os-volume-api-version 3.27 or greater is required to " + "support the 'volume attachment list' command" + ) + raise exceptions.CommandError(msg) + + 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 = { + 'all_tenants': True if project_id else parsed_args.all_projects, + 'project_id': project_id, + 'status': parsed_args.status, + 'volume_id': parsed_args.volume_id, + } + # Update search option with `filters` + # if AppendFilters.filters: + # search_opts.update(shell_utils.extract_filters(AppendFilters.filters)) + + # TODO(stephenfin): Implement sorting + attachments = volume_client.attachments.list( + search_opts=search_opts, + marker=parsed_args.marker, + limit=parsed_args.limit) + + column_headers = ( + 'ID', + 'Volume ID', + 'Server ID', + 'Status', + ) + columns = ( + 'id', + 'volume_id', + 'instance', + 'status', + ) + + return ( + column_headers, + ( + utils.get_item_properties(a, columns) + for a in attachments + ), + ) + + +class ShowVolumeAttachment(command.ShowOne): + """Show detailed information for a volume attachment.""" + + def get_parser(self, prog_name): + parser = super().get_parser(prog_name) + parser.add_argument( + 'attachment', + metavar='<attachment>', + help=_('ID of volume attachment.'), + ) + return parser + + def take_action(self, parsed_args): + volume_client = self.app.client_manager.volume + + if volume_client.api_version < api_versions.APIVersion('3.27'): + msg = _( + "--os-volume-api-version 3.27 or greater is required to " + "support the 'volume attachment show' command" + ) + raise exceptions.CommandError(msg) + + attachment = volume_client.attachments.show(parsed_args.attachment) + + return _format_attachment(attachment) diff --git a/releasenotes/notes/add-volume-attachment-commands-db2974c6460fa3bc.yaml b/releasenotes/notes/add-volume-attachment-commands-db2974c6460fa3bc.yaml new file mode 100644 index 0000000000..8355cea55a --- /dev/null +++ b/releasenotes/notes/add-volume-attachment-commands-db2974c6460fa3bc.yaml @@ -0,0 +1,8 @@ +--- +features: + - | + Add ``volume attachment create``, ``volume attachment delete``, + ``volume attachment list``, ``volume attachment complete``, + ``volume attachment set`` and ``volume attachment show`` commands to + create, delete, list, complete, update and show volume attachments, + respectively. diff --git a/setup.cfg b/setup.cfg index d6d7a3d2b0..792e5a8584 100644 --- a/setup.cfg +++ b/setup.cfg @@ -697,6 +697,13 @@ openstack.volume.v3 = volume_show = openstackclient.volume.v2.volume:ShowVolume volume_unset = openstackclient.volume.v2.volume:UnsetVolume + volume_attachment_create = openstackclient.volume.v3.volume_attachment:CreateVolumeAttachment + volume_attachment_delete = openstackclient.volume.v3.volume_attachment:DeleteVolumeAttachment + volume_attachment_list = openstackclient.volume.v3.volume_attachment:ListVolumeAttachment + volume_attachment_complete = openstackclient.volume.v3.volume_attachment:CompleteVolumeAttachment + volume_attachment_set = openstackclient.volume.v3.volume_attachment:SetVolumeAttachment + volume_attachment_show = openstackclient.volume.v3.volume_attachment:ShowVolumeAttachment + volume_backup_create = openstackclient.volume.v2.volume_backup:CreateVolumeBackup volume_backup_delete = openstackclient.volume.v2.volume_backup:DeleteVolumeBackup volume_backup_list = openstackclient.volume.v2.volume_backup:ListVolumeBackup