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