waiters: Add wait_for_volume_attachment_remove

This change adds a specific waiter for tracking the removal of
attachments from a volume. This should allow callers to track the
detaching of multiattached volumes from instances while the volume
remains in an `in-use` state.

Related-bug: #1858841
Change-Id: I85917f683d2bc81fdf3f6d3351bf56fdb649740e
diff --git a/tempest/common/waiters.py b/tempest/common/waiters.py
index 11f3bf9..b547cc6 100644
--- a/tempest/common/waiters.py
+++ b/tempest/common/waiters.py
@@ -217,6 +217,22 @@
              resource_name, resource_id, status, time.time() - start)
 
 
+def wait_for_volume_attachment_remove(client, volume_id, attachment_id):
+    """Waits for a volume attachment to be removed from a given volume."""
+    start = int(time.time())
+    attachments = client.show_volume(volume_id)['volume']['attachments']
+    while any(attachment_id == a['attachment_id'] for a in attachments):
+        time.sleep(client.build_interval)
+        if int(time.time()) - start >= client.build_timeout:
+            message = ('Failed to remove attachment %s from volume %s'
+                       'within the required time (%s s).' %
+                       (attachment_id, volume_id, client.build_timeout))
+            raise lib_exc.TimeoutException(message)
+        attachments = client.show_volume(volume_id)['volume']['attachments']
+    LOG.info('Attachment %s removed from volume %s after waiting for %f '
+             'seconds', attachment_id, volume_id, time.time() - start)
+
+
 def wait_for_volume_migration(client, volume_id, new_host):
     """Waits for a Volume to move to a new host."""
     body = client.show_volume(volume_id)['volume']
diff --git a/tempest/tests/common/test_waiters.py b/tempest/tests/common/test_waiters.py
index 6275f22..e3bb836 100755
--- a/tempest/tests/common/test_waiters.py
+++ b/tempest/tests/common/test_waiters.py
@@ -15,6 +15,7 @@
 import time
 
 import mock
+from oslo_utils.fixture import uuidsentinel as uuids
 
 from tempest.common import waiters
 from tempest import exceptions
@@ -232,3 +233,51 @@
         mock_show.assert_has_calls([mock.call(volume_id),
                                     mock.call(volume_id)])
         mock_sleep.assert_called_once_with(1)
+
+    def test_wait_for_volume_attachment(self):
+        vol_detached = {'volume': {'attachments': []}}
+        vol_attached = {'volume': {'attachments': [
+                       {'attachment_id': uuids.attachment_id}]}}
+        show_volume = mock.MagicMock(side_effect=[
+            vol_attached, vol_attached, vol_detached])
+        client = mock.Mock(spec=volumes_client.VolumesClient,
+                           build_interval=1,
+                           build_timeout=5,
+                           show_volume=show_volume)
+        self.patch('time.time')
+        self.patch('time.sleep')
+        waiters.wait_for_volume_attachment_remove(client, uuids.volume_id,
+                                                  uuids.attachment_id)
+        # Assert that show volume is called until the attachment is removed.
+        show_volume.assert_has_calls = [mock.call(uuids.volume_id),
+                                        mock.call(uuids.volume_id),
+                                        mock.call(uuids.volume_id)]
+
+    def test_wait_for_volume_attachment_timeout(self):
+        show_volume = mock.MagicMock(return_value={
+            'volume': {'attachments': [
+                {'attachment_id': uuids.attachment_id}]}})
+        client = mock.Mock(spec=volumes_client.VolumesClient,
+                           build_interval=1,
+                           build_timeout=1,
+                           show_volume=show_volume)
+        self.patch('time.time', side_effect=[0., client.build_timeout + 1.])
+        self.patch('time.sleep')
+        # Assert that a timeout is raised if the attachment remains.
+        self.assertRaises(lib_exc.TimeoutException,
+                          waiters.wait_for_volume_attachment_remove,
+                          client, uuids.volume_id, uuids.attachment_id)
+
+    def test_wait_for_volume_attachment_not_present(self):
+        show_volume = mock.MagicMock(return_value={
+            'volume': {'attachments': []}})
+        client = mock.Mock(spec=volumes_client.VolumesClient,
+                           build_interval=1,
+                           build_timeout=1,
+                           show_volume=show_volume)
+        self.patch('time.time', side_effect=[0., client.build_timeout + 1.])
+        self.patch('time.sleep')
+        waiters.wait_for_volume_attachment_remove(client, uuids.volume_id,
+                                                  uuids.attachment_id)
+        # Assert that show volume is only called once before we return
+        show_volume.assert_called_once_with(uuids.volume_id)