From c2475be001c347e671eff7ee0118d9dc82b3bfbf Mon Sep 17 00:00:00 2001 From: Marius Leustean Date: Sat, 22 Jun 2024 18:35:18 +0300 Subject: [PATCH] [vmware] Cache images as VM templates Upon user request, the driver can cache the image as a VM Template and reuse that to create the volume(s). This feature is useful when creating many volumes in parallel from the same image. We're not using the cinder built-in cache functionality because we need a few extra features: - the built-in cache doesn't account for shards. The cache entry will be placed on any backend/shard and could trigger a lot of slower cross-vc migrations when creating volumes from it. - the built-in cache doesn't have a periodic task for deleting the expired cache entries - we want to cache the images only when the customer requests it Users can request the image cache feature when creating the volume, by passing the use_image_cache='true' as a property (metadata). The feature must be enabled per backend, for example: ``` [vmware] enable_image_cache = true ``` This will enable the image cache feature for the vmware backend. The image templates will then be stored in a folder similar to the volumes folder: OpensStack/Project (vmware_image_cache)/Volumes, where {backend}_image_cache is used as a project name. The driver will periodically delete the cached images that are expired. The expiry time can be controlled via the property `image_cache_age_seconds` set on the backend configuration. Only images smaller than the configured `image_cache_max_size_gb` will be cached. Change-Id: I6f5e481f6997a180a455b47abe525b93bcf9aa4e --- cinder/scheduler/host_manager.py | 2 + .../tests/unit/scheduler/test_host_manager.py | 12 +- .../unit/volume/drivers/vmware/test_fcd.py | 1 + .../volume/drivers/vmware/test_vmware_vmdk.py | 119 ++++++- cinder/volume/drivers/vmware/vmdk.py | 312 ++++++++++++++++-- 5 files changed, 411 insertions(+), 35 deletions(-) diff --git a/cinder/scheduler/host_manager.py b/cinder/scheduler/host_manager.py index 0d0b86d5520..eb68e1987e4 100644 --- a/cinder/scheduler/host_manager.py +++ b/cinder/scheduler/host_manager.py @@ -386,6 +386,8 @@ def update_from_volume_capability(self, capability, service=None): # provisioned_capacity_gb if it is not set. self.provisioned_capacity_gb = capability.get( 'provisioned_capacity_gb', self.allocated_capacity_gb) + self.provisioned_capacity_gb += capability.get( + 'extra_provisioned_capacity_gb', 0) self.thin_provisioning_support = capability.get( 'thin_provisioning_support', False) self.thick_provisioning_support = capability.get( diff --git a/cinder/tests/unit/scheduler/test_host_manager.py b/cinder/tests/unit/scheduler/test_host_manager.py index 42b7f206094..7e4c71bc654 100644 --- a/cinder/tests/unit/scheduler/test_host_manager.py +++ b/cinder/tests/unit/scheduler/test_host_manager.py @@ -1280,6 +1280,7 @@ def test_update_from_volume_capability_nopool(self): 'free_capacity_gb': 512, 'provisioned_capacity_gb': 512, 'reserved_percentage': 0, + 'extra_provisioned_capacity_gb': 0, 'timestamp': None} fake_backend.update_from_volume_capability(volume_capability) @@ -1293,12 +1294,16 @@ def test_update_from_volume_capability_nopool(self): fake_backend.pools['_pool0'].provisioned_capacity_gb) # Test update for existing host state - volume_capability.update(dict(total_capacity_gb=1000)) + volume_capability.update(dict(total_capacity_gb=1000, + extra_provisioned_capacity_gb=100)) fake_backend.update_from_volume_capability(volume_capability) self.assertEqual(1000, fake_backend.pools['_pool0'].total_capacity_gb) + self.assertEqual(512 + 100, + fake_backend.pools['_pool0'].provisioned_capacity_gb) # Test update for existing host state with different backend name - volume_capability.update(dict(volume_backend_name='magic')) + volume_capability.update(dict(volume_backend_name='magic', + extra_provisioned_capacity_gb=0)) fake_backend.update_from_volume_capability(volume_capability) self.assertEqual(1000, fake_backend.pools['magic'].total_capacity_gb) self.assertEqual(512, fake_backend.pools['magic'].free_capacity_gb) @@ -1333,6 +1338,7 @@ def test_update_from_volume_capability_with_pools(self): 'free_capacity_gb': 1024, 'allocated_capacity_gb': 0, 'provisioned_capacity_gb': 0, + 'extra_provisioned_capacity_gb': 100, 'QoS_support': 'False', 'reserved_percentage': 0, 'dying_disks': 200, @@ -1364,7 +1370,7 @@ def test_update_from_volume_capability_with_pools(self): 1024, fake_backend.pools['2nd pool'].total_capacity_gb) self.assertEqual(1024, fake_backend.pools['2nd pool'].free_capacity_gb) self.assertEqual( - 0, fake_backend.pools['2nd pool'].provisioned_capacity_gb) + 100, fake_backend.pools['2nd pool'].provisioned_capacity_gb) capability = { 'volume_backend_name': 'Local iSCSI', diff --git a/cinder/tests/unit/volume/drivers/vmware/test_fcd.py b/cinder/tests/unit/volume/drivers/vmware/test_fcd.py index e3c483ffaea..beab1fcbc64 100644 --- a/cinder/tests/unit/volume/drivers/vmware/test_fcd.py +++ b/cinder/tests/unit/volume/drivers/vmware/test_fcd.py @@ -71,6 +71,7 @@ def setUp(self): self._config.reserved_percentage = self.RESERVED_PERCENTAGE self._config.vmware_datastores_as_pools = False self._config.vmware_snapshot_format = "COW" + self._config.enable_image_cache = False self._driver = fcd.VMwareVStorageObjectDriver( configuration=self._config) self._driver._vc_version = self.VC_VERSION diff --git a/cinder/tests/unit/volume/drivers/vmware/test_vmware_vmdk.py b/cinder/tests/unit/volume/drivers/vmware/test_vmware_vmdk.py index 0a5cced6d4d..d8bfd4ba257 100644 --- a/cinder/tests/unit/volume/drivers/vmware/test_vmware_vmdk.py +++ b/cinder/tests/unit/volume/drivers/vmware/test_vmware_vmdk.py @@ -123,6 +123,8 @@ def setUp(self): vmware_random_datastore_range=None, vmware_datastores_as_pools=False, allow_pulling_images_from_url=False, + enable_image_cache=False, + image_cache_max_size_gb=0, ) self._db = mock.Mock() @@ -167,20 +169,40 @@ def test_check_for_setup_error_fail(self, get_profile_id_by_name, session): get_profile_id_by_name.assert_called_once_with(session, self.STORAGE_PROFILE) + @mock.patch.object(VMDK_DRIVER, '_get_cached_images') @mock.patch.object(VMDK_DRIVER, 'session') - def test_get_volume_stats_no_pools(self, session): + def test_get_volume_stats_no_pools(self, session, mock_get_cached): + self._config.enable_image_cache = True retr_result_mock = mock.Mock(spec=['objects']) retr_result_mock.objects = [] session.vim.RetrievePropertiesEx.return_value = retr_result_mock session.vim.service_content.about.instanceUuid = 'fake-service' + mock_get_cached.return_value = [ + { + 'name': self.IMAGE_ID, + 'vm_ref': mock.sentinel.backing, + 'ds_ref': mock.sentinel.datastore, + 'disk_size': 100 * units.Gi, + 'created_at': None + }, + { + 'name': self.IMAGE_ID, + 'vm_ref': mock.sentinel.backing, + 'ds_ref': mock.sentinel.datastore, + 'disk_size': 200 * units.Gi, + 'created_at': None + } + ] stats = self._driver.get_volume_stats() + mock_get_cached.assert_called_once_with() self.assertEqual('VMware', stats['vendor_name']) self.assertEqual(self._driver.VERSION, stats['driver_version']) self.assertEqual('vmdk', stats['storage_protocol']) self.assertEqual(0, stats['reserved_percentage']) self.assertEqual(0, stats['total_capacity_gb']) self.assertEqual(0, stats['free_capacity_gb']) + self.assertEqual(300, stats['extra_provisioned_capacity_gb']) self.assertEqual(vmdk.LOCATION_DRIVER_NAME + ":fake-service", stats['location_info']) @@ -213,25 +235,44 @@ class props(object): class result(object): objects = [props()] - + ds_obj = vim_util.get_moref("datastore-85", "Datastore") datastores = {"datastore-85": {"summary": summary, - "storage_profile": {"name": "Gold"}}} + "storage_profile": {"name": "Gold"}, + "datastore_object": ds_obj}} return result(), datastores + @mock.patch.object(VMDK_DRIVER, '_get_cached_images') @mock.patch('cinder.volume.drivers.vmware.datastore.' 'DatastoreSelector.is_datastore_usable') @mock.patch.object(VMDK_DRIVER, '_collect_backend_stats') @mock.patch.object(VMDK_DRIVER, 'session') def test_get_volume_stats_pools(self, session, mock_stats, - datastore_usable): + datastore_usable, mock_get_cached): fake_result, fake_datastore_profiles = self._fake_stats_result() mock_stats.return_value = (fake_result, fake_datastore_profiles) datastore_usable.return_value = True self._config.vmware_datastores_as_pools = True + self._config.enable_image_cache = True self._driver = vmdk.VMwareVcVmdkDriver(configuration=self._config, additional_endpoints=[], db=self._db) self._driver._ds_sel = mock.MagicMock() + mock_get_cached.return_value = [ + { + 'name': self.IMAGE_ID, + 'vm_ref': mock.sentinel.backing, + 'ds_ref': vim_util.get_moref("datastore-85", "Datastore"), + 'disk_size': 100 * units.Gi, + 'created_at': None + }, + { + 'name': self.IMAGE_ID, + 'vm_ref': mock.sentinel.backing, + 'ds_ref': vim_util.get_moref("unknown-ds", "Datastore"), + 'disk_size': 200 * units.Gi, + 'created_at': None + } + ] retr_result_mock = mock.Mock(spec=['objects']) retr_result_mock.objects = [] @@ -239,6 +280,7 @@ def test_get_volume_stats_pools(self, session, mock_stats, session.vim.service_content.about.instanceUuid = 'fake-service' stats = self._driver.get_volume_stats() + mock_get_cached.assert_called_once_with() self.assertEqual('VMware', stats['vendor_name']) self.assertEqual(self._driver.VERSION, stats['driver_version']) self.assertEqual('vmdk', stats['storage_protocol']) @@ -247,6 +289,8 @@ def test_get_volume_stats_pools(self, session, mock_stats, self.assertEqual(0, stats["pools"][0]['reserved_percentage']) self.assertEqual(9313, stats["pools"][0]['total_capacity_gb']) self.assertEqual(4657, stats["pools"][0]['free_capacity_gb']) + self.assertEqual(100, + stats["pools"][0]["extra_provisioned_capacity_gb"]) self.assertEqual('up', stats["pools"][0]['pool_state']) self.assertEqual('up', stats["backend_state"]) self.assertFalse(stats["pools"][0]['multiattach']) @@ -287,6 +331,7 @@ def _create_volume_dict(self, 'size': size, 'volume_attachment': attachment, 'project_id': project_id, + 'metadata': {}, } def _create_volume_obj(self, @@ -838,6 +883,72 @@ def test_copy_image_to_volume_with_extend_backing(self, vmware_disk_type): def test_copy_image_to_volume_with_ova_container(self): self._test_copy_image_to_volume(container_format='ova') + @ddt.data( + ({'enable_image_cache': False}, + False), + ({'enable_image_cache': True, 'use_image_cache': 'N/A'}, + False), + ({'enable_image_cache': True, 'use_image_cache': 'true', + 'image_cache_max_size_gb': 1, 'image_size': 2 * units.Gi}, + False), + ({'enable_image_cache': True, 'use_image_cache': 'True', + 'image_cache_max_size_gb': 1, 'image_size': 2 * units.Gi}, + False), + ({'enable_image_cache': True, 'use_image_cache': 'true', + 'image_cache_max_size_gb': 2, 'image_size': 2 * units.Gi}, + True) + ) + @ddt.unpack + @mock.patch.object(VMDK_DRIVER, '_do_copy_image_to_volume') + @mock.patch.object(VMDK_DRIVER, 'volumeops') + @mock.patch.object(VMDK_DRIVER, '_extend_backing') + @mock.patch.object(VMDK_DRIVER, '_create_volume_from_cached_image') + @mock.patch.object(VMDK_DRIVER, + '_get_or_create_cached_image_backing') + def test_copy_image_to_volume_cached(self, params, expected, + mock_get_cached_backing, + mock_create_from_cached, + mock_extend_backing, + mock_volumeops, + mock_do_copy_image): + self._config.enable_image_cache = ( + params.get('enable_image_cache', False)) + self._config.image_cache_max_size_gb = ( + params.get('image_cache_max_size_gb', 0)) + + volume = self._create_volume_dict() + + use_image_cache = params.get('use_image_cache') + if use_image_cache: + volume['metadata']['use_image_cache'] = use_image_cache + + backing = mock.sentinel.backing + mock_volumeops.get_backing.return_value = backing + mock_volumeops.get_disk_size.return_value = self.VOL_SIZE * units.Gi + + mock_get_cached_backing.return_value = mock.sentinel.backing + + image_service = mock.Mock() + image_meta = self._create_image_meta( + size=params.get('image_size', 1 * units.Gi)) + image_service.show.return_value = image_meta + + self._driver.copy_image_to_volume( + mock.sentinel.context, volume, image_service, self.IMAGE_ID) + + if expected: + mock_do_copy_image.assert_not_called() + mock_get_cached_backing.assert_called_once_with( + mock.sentinel.context, volume, + image_service, self.IMAGE_ID, image_meta) + mock_create_from_cached.assert_called_once_with( + volume, backing) + else: + mock_get_cached_backing.assert_not_called() + mock_do_copy_image.assert_called_once_with( + mock.sentinel.context, volume, + image_service, self.IMAGE_ID, image_meta) + @mock.patch('cinder.volume.drivers.vmware.vmdk.VMwareVcVmdkDriver.' '_get_disk_type') @mock.patch.object(VMDK_DRIVER, '_check_disk_conversion') diff --git a/cinder/volume/drivers/vmware/vmdk.py b/cinder/volume/drivers/vmware/vmdk.py index 88e26a58544..b04529d0132 100644 --- a/cinder/volume/drivers/vmware/vmdk.py +++ b/cinder/volume/drivers/vmware/vmdk.py @@ -21,7 +21,7 @@ driver creates a virtual machine for each of the volumes. This virtual machine is never powered on and is often referred as the shadow VM. """ - +import itertools import math import re import ssl @@ -29,7 +29,9 @@ import OpenSSL from oslo_config import cfg from oslo_log import log as logging +from oslo_service import loopingcall from oslo_utils import excutils +from oslo_utils import timeutils from oslo_utils import units from oslo_utils import uuidutils from oslo_utils import versionutils @@ -44,10 +46,12 @@ from cinder import exception # This is needed to register the SAP config options from cinder.common import sap # noqa +from cinder import coordination from cinder.i18n import _ from cinder.image import image_utils from cinder import interface from cinder.objects import snapshot as snapshot_obj +from cinder.objects import volume as volume_obj from cinder.volume import configuration from cinder.volume import driver from cinder.volume.drivers.vmware import datastore as hub @@ -68,6 +72,7 @@ CREATE_PARAM_BACKING_NAME = 'name' CREATE_PARAM_DISK_SIZE = 'disk_size' CREATE_PARAM_TEMP_BACKING = 'temp_backing' +CREATE_PARAM_USE_IMAGE_CACHE = 'use_image_cache' TMP_IMAGES_DATASTORE_FOLDER_PATH = "cinder_temp/" @@ -220,6 +225,17 @@ 'cinder-volume container having to proxy the image between ' 'glance and VMware.' ), + cfg.BoolOpt('enable_image_cache', default=False, + help='Enable the image cache feature for the backend.'), + cfg.IntOpt('image_cache_max_size_gb', default=100, + help='Maximum size in GiB that an image is allowed to ' + 'have in order to be cached.'), + cfg.IntOpt('image_cache_purge_interval', default=600, + help='Number of seconds between runs of the periodic task ' + 'to purge the cached volume images.'), + cfg.IntOpt('image_cache_age_seconds', default=3600 * 24, + help='Minimum number of seconds after which a cached image ' + 'has to be deleted.'), ] CONF = cfg.CONF @@ -426,6 +442,8 @@ def check_for_setup_error(self): % storage_profile) raise exception.InvalidInput(reason=reason) + self._start_periodic_tasks() + def _init_vendor_properties(self): """Set some vmware specific properties.""" @@ -587,6 +605,11 @@ def _get_volume_stats(self): if not datastores: backend_state = 'down' data['backend_state'] = backend_state + + cached_images = [] + if self.configuration.enable_image_cache: + cached_images = self._get_cached_images() + if self.configuration.vmware_datastores_as_pools: pools = [] for ds_name in datastores: @@ -634,6 +657,15 @@ def _get_volume_stats(self): aggregate_id = \ custom_attributes['cinder_aggregate_id'] + # Calculate the extra capacity consumed by cached images + extra_capacity = 0 + for cached_img in cached_images: + ds_ref = cached_img['ds_ref'] + if (ds_ref and vim_util.get_moref_value(ds_ref) == + vim_util.get_moref_value( + datastore['datastore_object'])): + extra_capacity += cached_img['disk_size'] / units.Gi + pool = {'pool_name': summary.name, 'total_capacity_gb': round( summary.capacity / units.Gi), @@ -655,6 +687,8 @@ def _get_volume_stats(self): 'pool_down_reason': pool_down_reason, 'custom_attributes': custom_attributes, 'independent_snapshots': independent_snapshot, + 'extra_provisioned_capacity_gb': ( + int(math.ceil(extra_capacity))), } if aggregate_id: pool['aggregate_id'] = aggregate_id @@ -684,6 +718,11 @@ def _get_volume_stats(self): global_capacity += summary.capacity global_free += summary.freeSpace + # Calculate the extra capacity consumed by cached images + extra_capacity = 0 + for cached_img in cached_images: + extra_capacity += cached_img['disk_size'] / units.Gi + data_no_pools = { 'reserved_percentage': self.configuration.reserved_percentage, 'total_capacity_gb': round(global_capacity / units.Gi), @@ -692,6 +731,8 @@ def _get_volume_stats(self): 'thick_provisioning_support': True, 'max_over_subscription_ratio': max_over_subscription_ratio, 'connection_capabilities': connection_capabilities, + 'extra_provisioned_capacity_gb': ( + int(math.ceil(extra_capacity))), } data.update(data_no_pools) @@ -1919,33 +1960,8 @@ def clone_image(self, context, volume, image_location, image_meta, context, volume, image_service, image_meta['id']) return (ret, True) - def copy_image_to_volume(self, context, volume, image_service, image_id): - """Creates volume from image. - - This method only supports Glance image of VMDK disk format. - Uses flat vmdk file copy for "sparse" and "preallocated" disk types - Uses HttpNfc import API for "streamOptimized" disk types. This API - creates a backing VM that wraps the VMDK in the vCenter inventory. - - :param context: context - :param volume: Volume object - :param image_service: Glance image service - :param image_id: Glance image id - """ - LOG.debug("Copy glance image: %s to create new volume.", image_id) - - # Verify glance image is vmdk disk format - metadata = image_service.show(context, image_id) - VMwareVcVmdkDriver._validate_disk_format(metadata['disk_format']) - - # Validate container format; only 'bare' and 'ova' are supported. - container_format = metadata.get('container_format') - if (container_format and container_format not in ['bare', 'ova']): - msg = _("Container format: %s is unsupported, only 'bare' and " - "'ova' are supported.") % container_format - LOG.error(msg) - raise exception.ImageUnacceptable(image_id=image_id, reason=msg) - + def _do_copy_image_to_volume(self, context, volume, image_service, + image_id, metadata): # Get the disk type, adapter type and size of vmdk image image_disk_type = ImageDiskType.PREALLOCATED image_adapter_type = self._get_adapter_type(volume) @@ -1978,6 +1994,44 @@ def copy_image_to_volume(self, context, volume, image_service, image_id): "to volume: %(vol)s.", {'id': image_id, 'vol': volume['name']}) + def copy_image_to_volume(self, context, volume, image_service, image_id): + """Creates volume from image. + + This method only supports Glance image of VMDK disk format. + Uses flat vmdk file copy for "sparse" and "preallocated" disk types + Uses HttpNfc import API for "streamOptimized" disk types. This API + creates a backing VM that wraps the VMDK in the vCenter inventory. + + :param context: context + :param volume: Volume object + :param image_service: Glance image service + :param image_id: Glance image id + """ + LOG.debug("Copy glance image: %s to create new volume.", image_id) + + # Verify glance image is vmdk disk format + metadata = image_service.show(context, image_id) + VMwareVcVmdkDriver._validate_disk_format(metadata['disk_format']) + + # Validate container format; only 'bare' and 'ova' are supported. + container_format = metadata.get('container_format') + if (container_format and container_format not in ['bare', 'ova']): + msg = _("Container format: %s is unsupported, only 'bare' and " + "'ova' are supported.") % container_format + LOG.error(msg) + raise exception.ImageUnacceptable(image_id=image_id, reason=msg) + + img_backing = None + if self._can_use_image_cache(volume, metadata['size']): + img_backing = self._get_or_create_cached_image_backing( + context, volume, image_service, image_id, metadata) + + if img_backing: + self._create_volume_from_cached_image(volume, img_backing) + else: + self._do_copy_image_to_volume( + context, volume, image_service, image_id, metadata) + LOG.debug("Volume: %(id)s created from image: %(image_id)s.", {'id': volume['id'], 'image_id': image_id}) @@ -1998,6 +2052,58 @@ def copy_image_to_volume(self, context, volume, image_service, image_id): VMwareVcVmdkDriver._get_disk_type(volume)) # TODO(vbala): handle volume_size < disk_size case. + def _can_use_image_cache(self, volume, image_size): + requested = (volume['metadata'] + .get(CREATE_PARAM_USE_IMAGE_CACHE, "").lower() + == "true") + + if not requested: + return False + + if not self.configuration.enable_image_cache: + LOG.debug("Image cache was requested for volume %s, but it " + "wasn't enabled in the backend configuration.", + volume['id']) + return False + + max_size = self.configuration.image_cache_max_size_gb * units.Gi + if image_size > max_size: + LOG.debug("The requested image cannot be cached because it's " + "too big (%(image_size)s > %(max_size)s)", + {'image_size': image_size, + 'max_size': max_size}) + return False + + return True + + @coordination.synchronized("image-cache-{image_id}") + def _get_or_create_cached_image_backing(self, context, volume, + image_service, + image_id, metadata): + """Caches the image as VM Templates and returns the mo-ref""" + backing = self.volumeops.get_backing(image_id, image_id) + image_size_in_bytes = metadata['size'] + if not backing: + img_volume = volume_obj.Volume._from_db_object( + context, volume_obj.Volume(), dict( + id=image_id, + host=volume['host'], + volume_type_id=volume['volume_type_id'], + project_id=self._cache_project_name(), + size=image_size_in_bytes / units.Gi + )) + self._do_copy_image_to_volume( + context, img_volume, image_service, image_id, metadata) + backing = self.volumeops.get_backing(image_id, image_id) + cache_name = f"{self._cache_project_name()} ({image_id})" + self.volumeops.rename_backing(backing, cache_name) + self.volumeops.mark_backing_as_template(backing) + return backing + + def _cache_project_name(self): + backend_name = self.configuration.safe_get('volume_backend_name') + return f"{backend_name}_image_cache" + def copy_volume_to_image(self, context, volume, image_service, image_meta): """Creates glance image from volume. @@ -2895,6 +3001,26 @@ def _clone_backing(self, volume, backing, snapshot, clone_type, src_vsize): VMwareVcVmdkDriver._get_disk_type(volume)) LOG.info("Successfully created clone: %s.", clone) + def _create_volume_from_cached_image(self, volume, img_backing): + (host, rp, folder, summary) = self._select_ds_for_volume( + volume) + datastore = summary.datastore + disk_type = VMwareVcVmdkDriver._get_disk_type(volume) + + backing = self.volumeops.clone_backing( + volume['id'], + img_backing, + None, + volumeops.FULL_CLONE_TYPE, + datastore, + disk_type=disk_type, + host=host, + resource_pool=rp, + folder=folder) + + self.volumeops.update_backing_uuid(backing, volume['id']) + self.volumeops.update_backing_disk_uuid(backing, volume['id']) + @volume_utils.trace def _create_volume_from_template(self, volume, path): LOG.debug("Creating backing for volume: %(volume_id)s from template " @@ -3378,3 +3504,133 @@ def _migrate_to_fcd(self, context, volume, host): nova_api.update_server_volume(context, instance_uuid, volume.id, volume.id) return (True, None) + + def _start_periodic_tasks(self): + if self.configuration.enable_image_cache: + lock_name = f"{self._cache_project_name()}-purge_cache" + + @coordination.synchronized(lock_name) + def _purge_image_cache_locked(): + self._purge_image_cache() + + LOG.info("Starting the purge_image_cache periodic task.") + image_cache_ptask = loopingcall.FixedIntervalLoopingCall( + _purge_image_cache_locked,) + image_cache_ptask.start( + interval=self.configuration.image_cache_purge_interval, + stop_on_exception=False) + + def _purge_image_cache(self): + """Deletes expired images from the cache""" + cached_images = self._get_cached_images() + age_seconds = self.configuration.image_cache_age_seconds + for cached_image in cached_images: + if not timeutils.is_older_than( + cached_image['created_at'], + age_seconds): + continue + + try: + self.volumeops.delete_backing(cached_image['vm_ref']) + LOG.debug("Deleted expired image (%s) from the cache.", + cached_image['name']) + except exceptions.VimException: + LOG.exception("Failed to delete the expired image %s", + cached_image['name']) + + def _get_cached_images(self): + folder_refs = self._get_image_cache_folder_refs() + return list(itertools.chain( + *[self._get_cached_images_in_folder(folder_ref) + for folder_ref in folder_refs])) + + def _get_image_cache_folder_refs(self): + vim = self.session.vim + max_objects = self.configuration.vmware_max_objects_retrieval + retrieve_result = self.session.invoke_api( + vim_util, 'get_objects', vim, 'Datacenter', max_objects) + folder_refs = [] + with vim_util.WithRetrieval(vim, retrieve_result) as objects: + for dc_obj in objects: + if not dc_obj.propSet[0].val: + continue + folder_ref = self._get_volume_group_folder( + dc_obj.obj, self._cache_project_name()) + folder_refs.append(folder_ref) + return folder_refs + + def _get_cached_images_in_folder(self, folder_ref): + """Returns the cached images as list of dict + + [{"name": "", + "vm_ref": ManagedObjectReference, + "ds_ref": ManagedObjectReference, + "disk_size: 1024, + "created_at": datetime}] + + Where + - name: the name of the template VM ("Cinder Cache ({image_id})") + - vm_ref: ManagedObjectReference to the template VM + - ds_ref: ManagedObjectReference to the VM's datastore + - disk_size: size in bytes + - created_at: date when the image cache was created + """ + client_factory = self.session.vim.client.factory + prop_spec = vim_util.build_property_spec( + client_factory, 'VirtualMachine', + ['name', 'config.template', 'config.hardware.device', + 'config.createDate']) + obj_spec = vim_util.build_object_spec( + client_factory, folder_ref, + [vim_util.build_recursive_traversal_spec(client_factory)]) + filter_spec = vim_util.build_property_filter_spec( + client_factory, [prop_spec], [obj_spec]) + + options = client_factory.create('ns0:RetrieveOptions') + max_objects = self.configuration.vmware_max_objects_retrieval + options.maxObjects = max_objects + try: + retrieve_result = self.session.vim.RetrievePropertiesEx( + self.session.vim.service_content.propertyCollector, + specSet=[filter_spec], options=options) + except exceptions.VimFaultException as excep: + if exceptions.NOT_AUTHENTICATED in excep.fault_list: + # Check if session is active to decide if NotAuthenticated + # indicates empty result returned by RetrievePropertiesEx (as + # implemented in oslo.vmware) or it's a real exception. + if self.session.is_current_session_active(): + return [] + raise + + cached_images = [] + with vim_util.WithRetrieval(self.session.vim, + retrieve_result) as results: + for obj in results: + props = vim_util.propset_dict(obj.propSet) + if not props: + continue + name = props.get('name') + is_template = props.get('config.template') + devices = props.get('config.hardware.device') + created_at = props.get('config.createDate') + disk_size = 0 + ds_ref = None + if devices.__class__.__name__ == "ArrayOfVirtualDevice": + devices = devices.VirtualDevice + + for device in devices: + if device.__class__.__name__ == "VirtualDisk": + disk_size = device.capacityInKB * units.Ki + if device.backing: + ds_ref = device.backing.datastore + break + + if is_template and created_at: + cached_images.append({ + 'name': name, + 'vm_ref': obj.obj, + 'ds_ref': ds_ref, + 'disk_size': disk_size, + 'created_at': created_at + }) + return cached_images