# 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. """Cinder fixture.""" import collections import copy import fixtures from oslo_log import log as logging from oslo_utils.fixture import uuidsentinel as uuids from oslo_utils import uuidutils from nova import exception from nova.tests.fixtures import nova as nova_fixtures LOG = logging.getLogger(__name__) class CinderFixture(fixtures.Fixture): """A fixture to volume operations with the new Cinder attach/detach API""" # the default project_id in OSAPIFixtures tenant_id = nova_fixtures.PROJECT_ID SWAP_OLD_VOL = 'a07f71dc-8151-4e7d-a0cc-cd24a3f11113' SWAP_NEW_VOL = '227cc671-f30b-4488-96fd-7d0bf13648d8' SWAP_ERR_OLD_VOL = '828419fa-3efb-4533-b458-4267ca5fe9b1' SWAP_ERR_NEW_VOL = '9c6d9c2d-7a8f-4c80-938d-3bf062b8d489' SWAP_ERR_ATTACH_ID = '4a3cd440-b9c2-11e1-afa6-0800200c9a66' MULTIATTACH_VOL = '4757d51f-54eb-4442-8684-3399a6431f67' MULTIATTACH_RO_SWAP_OLD_VOL = uuids.multiattach_ro_swap_old_vol MULTIATTACH_RO_SWAP_NEW_VOL = uuids.multiattach_ro_swap_new_vol MULTIATTACH_RO_MIGRATE_OLD_VOL = uuids.multiattach_ro_migrate_old_vol MULTIATTACH_RO_MIGRATE_NEW_VOL = uuids.multiattach_ro_migrate_new_vol # This represents a bootable image-backed volume to test # boot-from-volume scenarios. IMAGE_BACKED_VOL = '6ca404f3-d844-4169-bb96-bc792f37de98' # This represents a bootable image-backed volume to test # boot-from-volume scenarios with # os_require_quiesce # hw_qemu_guest_agent IMAGE_BACKED_VOL_QUIESCE = '6ca404f3-d844-4169-bb96-bc792f37de26' # This represents a bootable image-backed volume with required traits # as part of volume image metadata IMAGE_WITH_TRAITS_BACKED_VOL = '6194fc02-c60e-4a01-a8e5-600798208b5f' # This represents a bootable volume backed by iSCSI storage. ISCSI_BACKED_VOL = uuids.iscsi_backed_volume # Dict of connection_info for the above volumes keyed by the volume id VOLUME_CONNECTION_INFO = { uuids.iscsi_backed_volume: { 'driver_volume_type': 'iscsi', 'data': { 'target_lun': '1' } }, 'fake': { 'driver_volume_type': 'fake', 'data': { 'foo': 'bar', } } } def __init__(self, test, az='nova'): """Initialize this instance of the CinderFixture. :param test: The TestCase using this fixture. :param az: The availability zone to return in volume GET responses. Defaults to "nova" since that is the default we would see from Cinder's storage_availability_zone config option. """ super().__init__() self.test = test self.swap_volume_instance_uuid = None self.swap_volume_instance_error_uuid = None self.attachment_error_id = None self.multiattach_ro_migrated = False self.az = az # A dict, keyed by volume id, to a dict, keyed by attachment id, # with keys: # - id: the attachment id # - instance_uuid: uuid of the instance attached to the volume # - connector: host connector dict; None if not connected # Note that a volume can have multiple attachments even without # multi-attach, as some flows create a blank 'reservation' attachment # before deleting another attachment. However, a non-multiattach volume # can only have at most one attachment with a host connector at a time. self.volume_to_attachment = collections.defaultdict(dict) def setUp(self): super().setUp() self._create_fakes() def _create_fakes(self): self.useFixture(fixtures.MockPatch( 'nova.volume.cinder.API.attachment_create', side_effect=self.fake_attachment_create, autospec=False)) self.useFixture(fixtures.MockPatch( 'nova.volume.cinder.API.attachment_update', side_effect=self.fake_attachment_update, autospec=False)) self.useFixture(fixtures.MockPatch( 'nova.volume.cinder.API.attachment_delete', side_effect=self.fake_attachment_delete, autospec=False)) self.useFixture(fixtures.MockPatch( 'nova.volume.cinder.API.attachment_complete', side_effect=self.fake_attachment_complete, autospec=False)) self.useFixture(fixtures.MockPatch( 'nova.volume.cinder.API.attachment_get', side_effect=self.fake_attachment_get, autospec=False)) self.useFixture(fixtures.MockPatch( 'nova.volume.cinder.API.begin_detaching', lambda *args, **kwargs: None)) self.useFixture(fixtures.MockPatch( 'nova.volume.cinder.API.get', side_effect=self.fake_get, autospec=False)) self.useFixture(fixtures.MockPatch( 'nova.volume.cinder.API.migrate_volume_completion', side_effect=self.fake_migrate_volume_completion, autospec=False)) self.useFixture(fixtures.MockPatch( 'nova.volume.cinder.API.roll_detaching', side_effect=(lambda *args, **kwargs: None), autospec=False)) self.useFixture(fixtures.MockPatch( 'nova.volume.cinder.is_microversion_supported', side_effect=(lambda ctxt, microversion: None), autospec=False)) self.useFixture(fixtures.MockPatch( 'nova.volume.cinder.API.check_attached', side_effect=(lambda *args, **kwargs: None), autospec=False)) self.useFixture(fixtures.MockPatch( 'nova.volume.cinder.API.get_all_volume_types', side_effect=self.fake_get_all_volume_types, autospec=False)) # TODO(lyarwood): These legacy cinderv2 APIs aren't currently wired # into the fixture but should be in the future before we migrate any # remaining legacy exports to cinderv3 attachments. self.useFixture(fixtures.MockPatch( 'nova.volume.cinder.API.initialize_connection', side_effect=(lambda *args, **kwargs: None), autospec=False)) self.useFixture(fixtures.MockPatch( 'nova.volume.cinder.API.terminate_connection', side_effect=lambda *args, **kwargs: None, autospec=False)) self.useFixture(fixtures.MockPatch( 'nova.volume.cinder.API.reimage_volume', side_effect=self.fake_reimage_volume, autospec=False)) self.useFixture(fixtures.MockPatch( 'nova.volume.cinder.API.get_absolute_limits', side_effect=self.fake_get_absolute_limits, autospec=False)) self.useFixture(fixtures.MockPatch( 'nova.volume.cinder.API.attachment_get_all', side_effect=self.fake_attachment_get_all, autospec=False)) def _is_multiattach(self, volume_id): return volume_id in [ self.MULTIATTACH_VOL, self.MULTIATTACH_RO_SWAP_OLD_VOL, self.MULTIATTACH_RO_SWAP_NEW_VOL, self.MULTIATTACH_RO_MIGRATE_OLD_VOL, self.MULTIATTACH_RO_MIGRATE_NEW_VOL] def _find_attachment(self, attachment_id): """Find attachment corresponding to ``attachment_id``. :returns: A tuple of the volume ID, an attachment dict for the given attachment ID, and a dict (keyed by attachment id) of attachment dicts for the volume. """ for volume_id, attachments in self.volume_to_attachment.items(): for attachment in attachments.values(): if attachment_id == attachment['id']: return volume_id, attachment, attachments raise exception.VolumeAttachmentNotFound( attachment_id=attachment_id) def _find_connection_info(self, volume_id, attachment_id): """Find the connection_info associated with an attachment :returns: A connection_info dict based on a deepcopy associated with the volume_id but containing both the attachment_id and volume_id making it unique for the attachment. """ connection_info = copy.deepcopy( self.VOLUME_CONNECTION_INFO.get( volume_id, self.VOLUME_CONNECTION_INFO.get('fake') ) ) connection_info['data']['volume_id'] = volume_id connection_info['data']['attachment_id'] = attachment_id return connection_info def fake_migrate_volume_completion( self, context, old_volume_id, new_volume_id, error, ): if new_volume_id == self.MULTIATTACH_RO_MIGRATE_NEW_VOL: # Mimic the behaviour of Cinder here that renames the new # volume to the old UUID once the migration is complete. # This boolean is used above to signal that the old volume # has been deleted if callers try to GET it. self.multiattach_ro_migrated = True return {'save_volume_id': old_volume_id} return {'save_volume_id': new_volume_id} def fake_get(self, context, volume_id, microversion=None): volume = { 'display_name': volume_id, 'id': volume_id, 'size': 1, 'multiattach': self._is_multiattach(volume_id), 'availability_zone': self.az } # Add any attachment details the fixture has fixture_attachments = self.volume_to_attachment[volume_id] if fixture_attachments: attachments = {} for attachment in list(fixture_attachments.values()): instance_uuid = attachment['instance_uuid'] # legacy cruft left over from notification tests if ( volume_id == self.SWAP_OLD_VOL and self.swap_volume_instance_uuid ): instance_uuid = self.swap_volume_instance_uuid if ( volume_id == self.SWAP_ERR_OLD_VOL and self.swap_volume_instance_error_uuid ): instance_uuid = self.swap_volume_instance_error_uuid attachments[instance_uuid] = { 'attachment_id': attachment['id'], 'mountpoint': '/dev/vdb', } volume.update({ 'status': 'in-use', 'attach_status': 'attached', 'attachments': attachments, }) # Otherwise mark the volume as available and detached else: volume.update({ 'status': 'available', 'attach_status': 'detached', }) if volume_id == self.IMAGE_BACKED_VOL: volume['bootable'] = True volume['volume_image_metadata'] = { 'image_id': '155d900f-4e14-4e4c-a73d-069cbf4541e6' } if volume_id == self.IMAGE_BACKED_VOL_QUIESCE: volume['bootable'] = True volume['volume_image_metadata'] = { "os_require_quiesce": "True", "hw_qemu_guest_agent": "True" } if volume_id == self.IMAGE_WITH_TRAITS_BACKED_VOL: volume['bootable'] = True volume['volume_image_metadata'] = { 'image_id': '155d900f-4e14-4e4c-a73d-069cbf4541e6', "trait:HW_CPU_X86_SGX": "required", } # If we haven't called migrate_volume_completion then return # a migration_status of migrating if ( volume_id == self.MULTIATTACH_RO_MIGRATE_OLD_VOL and not self.multiattach_ro_migrated ): volume['migration_status'] = 'migrating' # If we have migrated and are still GET'ing the new volume # return raise VolumeNotFound if ( volume_id == self.MULTIATTACH_RO_MIGRATE_NEW_VOL and self.multiattach_ro_migrated ): raise exception.VolumeNotFound( volume_id=self.MULTIATTACH_RO_MIGRATE_NEW_VOL) return volume def fake_get_all_volume_types(self, *args, **kwargs): return [{ # This is used in the 2.67 API sample test. 'id': '5f9204ec-3e94-4f27-9beb-fe7bb73b6eb9', 'name': 'lvm-1' }] def fake_attachment_get(self, context, attachment_id): # Ensure the attachment exists and grab the volume_id volume_id, _, _ = self._find_attachment(attachment_id) attachment_ref = { 'id': attachment_id, 'connection_info': self._find_connection_info( volume_id, attachment_id) } return attachment_ref def fake_attachment_create( self, context, volume_id, instance_uuid, connector=None, mountpoint=None, ): attachment_id = uuidutils.generate_uuid() if self.attachment_error_id is not None: attachment_id = self.attachment_error_id attachment = {'id': attachment_id} if connector: attachment['connection_info'] = self._find_connection_info( volume_id, attachment_id) self.volume_to_attachment[volume_id][attachment_id] = { 'id': attachment_id, 'instance_uuid': instance_uuid, 'connector': connector, } if volume_id in [self.MULTIATTACH_RO_SWAP_OLD_VOL, self.MULTIATTACH_RO_SWAP_NEW_VOL, self.MULTIATTACH_RO_MIGRATE_OLD_VOL, self.MULTIATTACH_RO_MIGRATE_NEW_VOL]: attachment['attach_mode'] = 'ro' LOG.info( 'Created attachment %s for volume %s. Total attachments ' 'for volume: %d', attachment_id, volume_id, len(self.volume_to_attachment[volume_id])) return attachment def fake_attachment_update( self, context, attachment_id, connector, mountpoint=None, ): # Ensure the attachment exists volume_id, attachment, attachments = self._find_attachment( attachment_id) # Cinder will only allow one "connected" attachment per # non-multiattach volume at a time. if volume_id != self.MULTIATTACH_VOL: for _attachment in attachments.values(): if _attachment['connector'] is not None: raise exception.InvalidInput( 'Volume %s is already connected with attachment ' '%s on host %s' % ( volume_id, _attachment['id'], _attachment['connector'].get('host'))) # If the mountpoint was provided stash it in the connector as we do # within nova.volume.cinder.API.attachment_update before calling # c-api and then stash the connector in the attachment record. if mountpoint: connector['device'] = mountpoint attachment['connector'] = connector LOG.info('Updating volume attachment: %s', attachment_id) attachment_ref = { 'id': attachment_id, 'connection_info': self._find_connection_info( volume_id, attachment_id) } if attachment_id == self.SWAP_ERR_ATTACH_ID: # This intentionally triggers a TypeError for the # instance.volume_swap.error versioned notification tests. attachment_ref = {'connection_info': ()} return attachment_ref def fake_attachment_complete(self, _context, attachment_id): # Ensure the attachment exists self._find_attachment(attachment_id) LOG.info('Completing volume attachment: %s', attachment_id) def fake_attachment_delete(self, context, attachment_id): # 'attachment' is a tuple defining a attachment-instance mapping volume_id, attachment, attachments = ( self._find_attachment(attachment_id)) del attachments[attachment_id] LOG.info( 'Deleted attachment %s for volume %s. Total attachments ' 'for volume: %d', attachment_id, volume_id, len(attachments)) def fake_reimage_volume(self, *args, **kwargs): if self.IMAGE_BACKED_VOL not in args: raise exception.VolumeNotFound() if 'reimage_reserved' not in kwargs: raise exception.InvalidInput('reimage_reserved not specified') def fake_get_absolute_limits(self, context): limits = {'totalSnapshotsUsed': 0, 'maxTotalSnapshots': -1} return limits def fake_attachment_get_all( self, context, instance_id=None, volume_id=None): if not instance_id and not volume_id: raise exception.InvalidRequest( "Either instance or volume id must be passed.") if volume_id in self.volume_to_attachment: return self.volume_to_attachment[volume_id] all_attachments = [] for _, attachments in self.volume_to_attachment.items(): all_attachments.extend( [attach for attach in attachments.values() if instance_id == attach['instance_uuid']]) return all_attachments def volume_ids_for_instance(self, instance_uuid): for volume_id, attachments in self.volume_to_attachment.items(): for attachment in attachments.values(): if attachment['instance_uuid'] == instance_uuid: # we might have multiple volumes attached to this instance # so yield rather than return yield volume_id break def attachment_ids_for_instance(self, instance_uuid): attachment_ids = [] for volume_id, attachments in self.volume_to_attachment.items(): for attachment in attachments.values(): if attachment['instance_uuid'] == instance_uuid: attachment_ids.append(attachment['id']) return attachment_ids def create_vol_attachment(self, volume_id, instance_id): attachment_id = uuidutils.generate_uuid() if self.attachment_error_id is not None: attachment_id = self.attachment_error_id attachment = {'id': attachment_id} self.volume_to_attachment[volume_id][attachment_id] = { 'id': attachment_id, 'instance_uuid': instance_id, } return attachment def get_vol_attachment(self, _id): for _, attachments in self.volume_to_attachment.items(): for attachment_id in attachments: if _id == attachment_id: # return because attachment id is unique return attachments[attachment_id] def delete_vol_attachment(self, vol_id): del self.volume_to_attachment[vol_id]