diff --git a/cinder/action_track.py b/cinder/action_track.py new file mode 100644 index 00000000000..73c34d531fa --- /dev/null +++ b/cinder/action_track.py @@ -0,0 +1,200 @@ +# Copyright 2023 Openstack Foundation. +# All Rights Reserved. +# +# 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 abc +import inspect +import traceback + +import decorator +from oslo_config import cfg +from oslo_log import log as logging +from oslo_utils import excutils + +from cinder import context as cinder_context + + +CONF = cfg.CONF +LOG = logging.getLogger(__name__) + +ACTION_VOLUME_CREATE = "volume_create" +ACTION_VOLUME_DELETE = "volume_delete" +ACTION_VOLUME_RESERVE = "volume_reserve" +ACTION_VOLUME_ATTACH = "volume_attach" +ACTION_VOLUME_EXTEND = "volume_extend" +ACTION_VOLUME_DETACH = "volume_detach" +ACTION_VOLUME_MIGRATE = "volume_migrate" +ACTION_VOLUME_RETYPE = "volume_retype" +ACTION_VOLUME_BACKUP = "volume_backup" +ACTION_BACKUP_RESTORE = "volume_restore" +ACTION_VOLUME_BACKUP_DELETE = "volume_backup_delete" +ACTION_VOLUME_COPY_TO_IMAGE = "volume_copy_to_image" +ACTION_VOLUME_BACKUP_RESET_STATUS = "volume_backup_reset_status" +ACTION_SNAPSHOT_CREATE = "snapshot_create" +ACTION_SNAPSHOT_DELETE = "snapshot_delete" + + +VALID_RESOURCE_NAMES = [ + 'volume', 'backup', 'snapshot' +] +VALID_CONTEXT_NAMES = [ + 'context', 'ctxt' +] + +log_level_map = { + logging.CRITICAL: LOG.error, + logging.ERROR: LOG.error, + logging.WARNING: LOG.warning, + logging.INFO: LOG.info, + logging.DEBUG: LOG.debug, +} + + +class ActionTrack(object, metaclass=abc.ABCMeta): + """Base class for the 'trace' api. + + The purpose of this trace facility is to be able to + keep track of critical parts of operations against resources. + This will create a standardized object/log entry for troubleshooting + actions against resources. + + This is not for performance tracing, but for action/operation tracking. + by default it will simply format a log entry such that the entries are + easy to find with a standard format and information. + """ + + @staticmethod + @abc.abstractmethod + def track(context, action, resource, message, loglevel=logging.INFO): + pass + + @staticmethod + @abc.abstractmethod + def track_with_file_info(context, action, resource, message, + filename, line_number, function, + loglevel=logging.INFO): + pass + + +class LogActionTrack(ActionTrack): + @staticmethod + def _track_with_info(context, action, resource, message, + filename, line_number, function, + loglevel=logging.INFO): + entry = f"ACTION:'{action}' " + if loglevel == logging.ERROR or loglevel == logging.CRITICAL: + # The action failed and this trace is the reason + entry += "FAILED " + + msg = message.replace("\n", "") + + entry += ( + f"MSG:'{msg}' " + f"FILE:{filename}:{line_number}:{function} " + f"RSC:{resource} " + ) + log_func = log_level_map[loglevel] + log_func(entry, resource=resource) + + @staticmethod + def track(context, action, resource, message, loglevel=logging.INFO): + # Do not call this directly. Call action_track.track() instead. + + # We only want the frame of the caller + # we should always be called from the trace() method in this module + # not called directly in this static method + info = list(traceback.walk_stack(None))[1][0] + LogActionTrack._track_with_info(context, action, resource, message, + info.f_code.co_filename, + info.f_lineno, + info.f_code.co_name, + loglevel=loglevel) + + @staticmethod + def track_with_file_info(context, action, resource, message, + filename, line_number, function, + loglevel=logging.INFO): + # Do not call this directly. + # Call action_track.track_with_file_info() instead. + LogActionTrack._track_with_info( + context, action, resource, message, + filename, line_number, function, loglevel=loglevel + ) + + +def track(context, action, resource, message, loglevel=logging.INFO): + """For now we only implement LogActionTrack. + + TODO(waboring): add rabbitmq trace? to send entries to a msg queue + or add: DBtrace to send traces to the DB instead? + """ + LogActionTrack.track(context, action, resource, message, + loglevel=loglevel) + + +def track_with_info(context, action, resource, message, file, line_number, + function, loglevel=logging.INFO): + """For now we only implement LogActionTrack. + + TODO(waboring): add rabbitmq trace? to send entries to a msg queue + or add: DBtrace to send traces to the DB instead? + """ + LogActionTrack.track_with_file_info(context, action, resource, message, + file, line_number, function, + loglevel=loglevel) + + +def track_decorator(action): + """Decorator to automatically handle exceptions raised as failures. + + Place this decorator on a function that you want to mark as an + action failure and the action_track tracing will get called + for the action. + + @track_decorator(action_track.ACTION_VOLUME_ATTACH) + def initialize_connection(....) + If initialize_connection raises an exception then you will get a + action_track.track called with action of ACTION_VOLUME_ATTACH + and a failure. + + """ + @decorator.decorator + def inner(func, *args, **kwargs): + # Find the context and the volume/backup object + resource = None + context = None + call_args = inspect.getcallargs(func, *args, **kwargs) + for key in call_args: + if key in VALID_RESOURCE_NAMES: + resource = call_args[key] + elif (key in VALID_CONTEXT_NAMES and + isinstance(call_args[key], cinder_context.RequestContext)): + context = call_args[key] + + track(context, action, resource, "called") + + try: + return func(*args, **kwargs) + except Exception: + with excutils.save_and_reraise_exception() as exc: + # We only want the frame of the caller + tl = traceback.extract_tb(exc.tb) + i = tl[1] + message = str(exc.value) + track_with_info( + context, action, resource, message, + i.filename, i.lineno, i.name, + loglevel=logging.ERROR + ) + return inner diff --git a/cinder/api/common.py b/cinder/api/common.py index 4320527862c..f3c53566fa7 100644 --- a/cinder/api/common.py +++ b/cinder/api/common.py @@ -54,6 +54,8 @@ METADATA_TYPES = enum.Enum('METADATA_TYPES', 'user image') +SAP_HIDDEN_METADATA_KEY = "__cinder_internal" + def get_pagination_params(params, max_limit=None): """Return marker, limit, offset tuple from request. diff --git a/cinder/api/contrib/services.py b/cinder/api/contrib/services.py index 2d7ddca3100..d81941ad4f0 100644 --- a/cinder/api/contrib/services.py +++ b/cinder/api/contrib/services.py @@ -153,6 +153,15 @@ def _failover(self, req, context, clustered, body): cluster_name, body.get('backend_id')) return webob.Response(status_int=HTTPStatus.ACCEPTED) + @validation.schema(os_services.recount_host_stats) + def _recount_host_stats(self, req, context, body): + """Ask the volume manager to recount allocated capacity for host.""" + cluster_name, host = common.get_cluster_host(req, body, + mv.REPLICATION_CLUSTER) + self._volume_api_proxy(self.volume_api.recount_host_stats, context, + host) + return webob.Response(status_int=HTTPStatus.ACCEPTED) + def _log_params_binaries_services(self, context, body): """Get binaries and services referred by given log set/get request.""" query_filters = {'is_up': True} @@ -273,6 +282,8 @@ def update(self, req, id, body): return self._set_log(req, context, body=body) elif support_dynamic_log and id == 'get-log': return self._get_log(req, context, body=body) + elif id == "recount_host_stats": + return self._recount_host_stats(req, context, body=body) else: raise exception.InvalidInput(reason=_("Unknown action")) diff --git a/cinder/api/contrib/vmware_migrate.py b/cinder/api/contrib/vmware_migrate.py new file mode 100644 index 00000000000..d4ea467f772 --- /dev/null +++ b/cinder/api/contrib/vmware_migrate.py @@ -0,0 +1,74 @@ +# Copyright (c) 2021 SAP Corporation +# All Rights Reserved. +# +# 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. + +"""The Migrate by connector API.""" + + +from oslo_log import log as logging +from oslo_utils import strutils +from six.moves import http_client + +from cinder.api.contrib import admin_actions +from cinder.api import extensions +from cinder.api.openstack import wsgi +from cinder.api.schemas import vmware_extension_actions as vmware_actions +from cinder.api import validation + + +LOG = logging.getLogger(__name__) + + +class VMWareVolumeExtensionsController(admin_actions.VolumeAdminController): + + collection = 'volumes' + + @wsgi.response(http_client.ACCEPTED) + @wsgi.action('os-migrate_volume_by_connector') + @validation.schema(vmware_actions.migrate_volume_by_connector) + def _migrate_volume_by_connector(self, req, id, body): + """Migrate a volume based on connector. + + This is an SAP VMWare extension that requires + the connector of the vmware vcenter to be provided. + The connector will contain the correct vcenter uuid + so that the scheduler can find the right cinder backend + to migrate the volume. + """ + context = req.environ['cinder.context'] + # Not found exception will be handled at the wsgi level + volume = self._get(context, id) + self.authorize(context, 'migrate_volume', target_obj=volume) + params = body['os-migrate_volume_by_connector'] + connector = params.get('connector', {}) + lock_volume = strutils.bool_from_string( + params.get('lock_volume', False), + strict=True) + + self.volume_api.migrate_volume_by_connector( + context, volume, connector, lock_volume) + + +class Vmware_migrate(extensions.ExtensionDescriptor): + """Enable admin actions.""" + + name = "Vmware_migrate" + alias = "os-vmware-admin-actions" + updated = "2021-09-25T00:00:00+00:00" + + def get_controller_extensions(self): + controller = VMWareVolumeExtensionsController() + extension = extensions.ControllerExtension( + self, controller.collection, controller) + return [extension] diff --git a/cinder/api/contrib/volume_actions.py b/cinder/api/contrib/volume_actions.py index deb326be079..96f3fb68304 100644 --- a/cinder/api/contrib/volume_actions.py +++ b/cinder/api/contrib/volume_actions.py @@ -12,6 +12,7 @@ # License for the specific language governing permissions and limitations # under the License. from http import HTTPStatus +import logging from castellan import key_manager from oslo_config import cfg @@ -19,6 +20,7 @@ from oslo_utils import strutils import webob +from cinder import action_track from cinder.api import extensions from cinder.api import microversions as mv from cinder.api.openstack import wsgi @@ -77,11 +79,20 @@ def _attach(self, req, id, body): msg = _("Error attaching volume - %(err_type)s: " "%(err_msg)s") % { 'err_type': error.exc_type, 'err_msg': error.value} + action_track.track( + context, action_track.ACTION_VOLUME_ATTACH, + volume, msg, loglevel=logging.ERROR + ) raise webob.exc.HTTPBadRequest(explanation=msg) else: # There are also few cases where attach call could fail due to # db or volume driver errors. These errors shouldn't be exposed # to the user and in such cases it should raise 500 error. + action_track.track( + context, action_track.ACTION_VOLUME_ATTACH, + volume, f"Attach failed because {error}", + loglevel=logging.ERROR + ) raise @wsgi.response(HTTPStatus.ACCEPTED) @@ -159,16 +170,40 @@ def _initialize_connection(self, req, id, body): volume = self.volume_api.get(context, id) connector = body['os-initialize_connection']['connector'] try: + action_track.track( + context, action_track.ACTION_VOLUME_ATTACH, + volume, "Call initialize_connection", + ) info = self.volume_api.initialize_connection(context, volume, connector) except exception.InvalidInput as err: + action_track.track( + context, action_track.ACTION_VOLUME_ATTACH, + volume, "Invalid Input", loglevel=logging.ERROR + ) raise webob.exc.HTTPBadRequest( explanation=err.msg) + except exception.ConnectorRejected: + msg = _("Volume needs to be migrated before attaching to this " + "instance") + action_track.track( + context, action_track.ACTION_VOLUME_ATTACH, + volume, msg + ) + raise webob.exc.HTTPNotAcceptable(explanation=msg) except exception.VolumeBackendAPIException: msg = _("Unable to fetch connection information from backend.") + action_track.track( + context, action_track.ACTION_VOLUME_ATTACH, + volume, msg, loglevel=logging.ERROR + ) raise webob.exc.HTTPInternalServerError(explanation=msg) except messaging.RemoteError as error: + action_track.track( + context, action_track.ACTION_VOLUME_ATTACH, + volume, f"Failed because {error}", loglevel=logging.ERROR + ) if error.exc_type == 'InvalidInput': raise exception.InvalidInput(reason=error.value) raise diff --git a/cinder/api/schemas/services.py b/cinder/api/schemas/services.py index eb094c1c2b5..d03522c61b5 100644 --- a/cinder/api/schemas/services.py +++ b/cinder/api/schemas/services.py @@ -82,3 +82,12 @@ }, 'additionalProperties': False, } + + +recount_host_stats = { + 'type': 'object', + 'properties': { + 'host': parameter_types.hostname, + }, + 'additionalProperties': False, +} diff --git a/cinder/api/schemas/vmware_extension_actions.py b/cinder/api/schemas/vmware_extension_actions.py new file mode 100644 index 00000000000..357647d830d --- /dev/null +++ b/cinder/api/schemas/vmware_extension_actions.py @@ -0,0 +1,33 @@ +# Copyright (C) 2021 SAP +# All Rights Reserved. +# +# 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 cinder.api.validation import parameter_types + + +migrate_volume_by_connector = { + 'type': 'object', + 'properties': { + 'os-migrate_volume_by_connector': { + 'type': 'object', + 'properties': { + 'connector': {'type': ['string', 'object', 'null']}, + 'lock_volume': parameter_types.boolean, + }, + 'additionalProperties': False, + }, + }, + 'required': ['os-migrate_volume_by_connector'], + 'additionalProperties': False, +} diff --git a/cinder/api/v3/attachments.py b/cinder/api/v3/attachments.py index f0c4110ccb5..d24520e7607 100644 --- a/cinder/api/v3/attachments.py +++ b/cinder/api/v3/attachments.py @@ -16,6 +16,7 @@ from oslo_log import log as logging import webob +from cinder import action_track from cinder.api import api_utils from cinder.api import common from cinder.api import microversions as mv @@ -198,13 +199,31 @@ def create(self, req, body): except (exception.NotAuthorized, exception.InvalidVolume): raise + except exception.ConnectorRejected: + # Don't use err_msg or it will raise the 500 + _msg = _("Volume needs to be migrated before attaching to this " + "instance") + LOG.exception(_msg) + action_track.track( + context, action_track.ACTION_VOLUME_ATTACH, + volume_ref, _msg, loglevel=logging.ERROR + ) + raise webob.exc.HTTPNotAcceptable(explanation=_msg) except exception.CinderException as ex: err_msg = _( "Unable to create attachment for volume (%s).") % ex.msg LOG.exception(err_msg) + action_track.track( + context, action_track.ACTION_VOLUME_ATTACH, + volume_ref, err_msg, loglevel=logging.ERROR + ) except Exception: err_msg = _("Unable to create attachment for volume.") LOG.exception(err_msg) + action_track.track( + context, action_track.ACTION_VOLUME_ATTACH, + volume_ref, err_msg, loglevel=logging.ERROR + ) finally: if err_msg: raise webob.exc.HTTPInternalServerError(explanation=err_msg) @@ -252,13 +271,31 @@ def update(self, req, id, body): connector)) except exception.NotAuthorized: raise + except exception.ConnectorRejected: + # Don't use err_msg or it will raise the 500 + _msg = _("Volume needs to be migrated before attaching to this " + "instance") + LOG.exception(_msg) + action_track.track( + context, action_track.ACTION_VOLUME_ATTACH, + attachment_ref, _msg, loglevel=logging.ERROR + ) + raise webob.exc.HTTPNotAcceptable(explanation=_msg) except exception.CinderException as ex: err_msg = ( _("Unable to update attachment.(%s).") % ex.msg) LOG.exception(err_msg) + action_track.track( + context, action_track.ACTION_VOLUME_ATTACH, + attachment_ref, err_msg, loglevel=logging.ERROR + ) except Exception: err_msg = _("Unable to update the attachment.") LOG.exception(err_msg) + action_track.track( + context, action_track.ACTION_VOLUME_ATTACH, + attachment_ref, err_msg, loglevel=logging.ERROR + ) finally: if err_msg: raise webob.exc.HTTPInternalServerError(explanation=err_msg) @@ -300,6 +337,10 @@ def complete(self, req, id, body): attachment_ref.save() volume_ref.update({'status': 'in-use', 'attach_status': 'attached'}) volume_ref.save() + action_track.track( + context, action_track.ACTION_VOLUME_ATTACH, + volume_ref, "Volume attachment completed!" + ) volume_utils.notify_about_volume_usage(context, volume_ref, "attach.end") diff --git a/cinder/api/views/snapshots.py b/cinder/api/views/snapshots.py index 7d562307c4d..467d380a3d0 100644 --- a/cinder/api/views/snapshots.py +++ b/cinder/api/views/snapshots.py @@ -39,6 +39,13 @@ def summary(self, request, snapshot): """Generic, non-detailed view of a snapshot.""" if isinstance(snapshot.metadata, dict): metadata = snapshot.metadata + # SAP we don't show the backend here because it's + # custom for our deployment with independent snaps + # for the vmware vmdk driver + del_key = common.SAP_HIDDEN_METADATA_KEY + delete_keys = [key for key in metadata if key.startswith(del_key)] + for key in delete_keys: + del metadata[key] else: metadata = {} diff --git a/cinder/backup/api.py b/cinder/backup/api.py index 30a036c233c..985488c57c0 100644 --- a/cinder/backup/api.py +++ b/cinder/backup/api.py @@ -49,8 +49,16 @@ help='Backup services use same backend.') ] +sap_backup_opts = [ + cfg.BoolOpt('sap_disable_incremental_backup', + default=False, + help='Silently disable incremental backup.') +] + + CONF = cfg.CONF CONF.register_opts(backup_opts) +CONF.register_opts(sap_backup_opts) LOG = logging.getLogger(__name__) QUOTAS = quota.QUOTAS IMPORT_VOLUME_ID = '00000000-0000-0000-0000-000000000000' @@ -202,6 +210,11 @@ def create(self, context, name, description, volume_id, """Make the RPC call to create a volume backup.""" volume = self.volume_api.get(context, volume_id) context.authorize(policy.CREATE_POLICY, target_obj=volume) + if CONF.sap_disable_incremental_backup: + # This means force incremental to be OFF + incremental = False + LOG.info("Incremental Backups have been silently disabled." + " Will do a full backup.") snapshot = None if snapshot_id: snapshot = self.volume_api.get_snapshot(context, snapshot_id) diff --git a/cinder/backup/chunkeddriver.py b/cinder/backup/chunkeddriver.py index b780d51dcca..9323a6af599 100644 --- a/cinder/backup/chunkeddriver.py +++ b/cinder/backup/chunkeddriver.py @@ -3,6 +3,7 @@ # Copyright (c) 2015 EMC Corporation # Copyright (C) 2015 Kevin Fox # Copyright (C) 2015 Tom Barron +# Copyright (C) 2020 SAP SE # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); you may @@ -23,6 +24,7 @@ import abc import hashlib +import io import json import os import sys @@ -32,6 +34,7 @@ from oslo_log import log as logging from oslo_service import loopingcall from oslo_utils import excutils +from oslo_utils import importutils from oslo_utils import secretutils from oslo_utils import units @@ -80,7 +83,9 @@ class ChunkedBackupDriver(driver.BackupDriver, metaclass=abc.ABCMeta): """ DRIVER_VERSION = '1.0.0' - DRIVER_VERSION_MAPPING = {'1.0.0': '_restore_v1'} + DRIVER_VERSION_MAPPING = { + '1.0.0': 'cinder.backup.chunkeddriver.BackupRestoreHandleV1' + } def _get_compressor(self, algorithm): try: @@ -364,10 +369,9 @@ def _backup_chunk(self, backup, container, data, data_offset, obj[object_name] = {} obj[object_name]['offset'] = data_offset obj[object_name]['length'] = len(data) - LOG.debug('Backing up chunk of data from volume.') algorithm, output_data = self._prepare_output_data(data) obj[object_name]['compression'] = algorithm - LOG.debug('About to put_object') + LOG.debug('About to put_object : %s', obj[object_name]) with self._get_object_writer( container, object_name, extra_metadata=extra_metadata ) as writer: @@ -582,6 +586,7 @@ def _notify_progress(): win32_disk_size - data_offset) else: read_bytes = self.chunk_size_bytes + LOG.debug("reading '%s' bytes", read_bytes) data = volume_file.read(read_bytes) if data == b'': @@ -660,89 +665,6 @@ def _notify_progress(): self._finalize_backup(backup, container, object_meta, object_sha256) - def _restore_v1(self, backup, volume_id, metadata, volume_file, - requested_backup): - """Restore a v1 volume backup. - - Raises BackupRestoreCancel on any requested_backup status change, we - ignore the backup parameter for this check since that's only the - current data source from the list of backup sources. - """ - backup_id = backup['id'] - LOG.debug('v1 volume backup restore of %s started.', backup_id) - extra_metadata = metadata.get('extra_metadata') - container = backup['container'] - metadata_objects = metadata['objects'] - metadata_object_names = [] - for obj in metadata_objects: - metadata_object_names.extend(obj.keys()) - LOG.debug('metadata_object_names = %s.', metadata_object_names) - prune_list = [self._metadata_filename(backup), - self._sha256_filename(backup)] - object_names = [object_name for object_name in - self._generate_object_names(backup) - if object_name not in prune_list] - if sorted(object_names) != sorted(metadata_object_names): - err = _('restore_backup aborted, actual object list ' - 'does not match object list stored in metadata.') - raise exception.InvalidBackup(reason=err) - - for metadata_object in metadata_objects: - # Abort when status changes to error, available, or anything else - with requested_backup.as_read_deleted(): - requested_backup.refresh() - if requested_backup.status != fields.BackupStatus.RESTORING: - raise exception.BackupRestoreCancel(back_id=backup.id, - vol_id=volume_id) - - object_name, obj = list(metadata_object.items())[0] - LOG.debug('restoring object. backup: %(backup_id)s, ' - 'container: %(container)s, object name: ' - '%(object_name)s, volume: %(volume_id)s.', - { - 'backup_id': backup_id, - 'container': container, - 'object_name': object_name, - 'volume_id': volume_id, - }) - - with self._get_object_reader( - container, object_name, - extra_metadata=extra_metadata) as reader: - body = reader.read() - compression_algorithm = metadata_object[object_name]['compression'] - decompressor = self._get_compressor(compression_algorithm) - volume_file.seek(obj['offset']) - if decompressor is not None: - LOG.debug('decompressing data using %s algorithm', - compression_algorithm) - decompressed = decompressor.decompress(body) - body = None # Allow Python to free it - volume_file.write(decompressed) - decompressed = None # Allow Python to free it - else: - volume_file.write(body) - body = None # Allow Python to free it - - # force flush every write to avoid long blocking write on close - volume_file.flush() - - # Be tolerant to IO implementations that do not support fileno() - try: - fileno = volume_file.fileno() - except IOError: - LOG.info("volume_file does not support fileno() so skipping " - "fsync()") - else: - os.fsync(fileno) - - # Restoring a backup to a volume can take some time. Yield so other - # threads can run, allowing for among other things the service - # status to be updated - eventlet.sleep(0) - LOG.debug('v1 volume backup restore of %s finished.', - backup_id) - def restore(self, backup, volume_id, volume_file): """Restore the given volume backup from backup repository. @@ -764,9 +686,12 @@ def restore(self, backup, volume_id, volume_file): metadata_version = metadata['version'] LOG.debug('Restoring backup version %s', metadata_version) try: - restore_func = getattr(self, self.DRIVER_VERSION_MAPPING.get( - metadata_version)) - except TypeError: + restore_handle = importutils.import_object( + self.DRIVER_VERSION_MAPPING[metadata_version], + self, + volume_id, + volume_file) + except (KeyError, ImportError): err = (_('No support to restore backup version %s') % metadata_version) raise exception.InvalidBackup(reason=err) @@ -782,14 +707,13 @@ def restore(self, backup, volume_id, volume_file): backup_list.append(prev_backup) current_backup = prev_backup - # Do a full restore first, then layer the incremental backups - # on top of it in order. + # Layer the backups in order, from the parent to the last child index = len(backup_list) - 1 while index >= 0: backup1 = backup_list[index] index = index - 1 metadata = self._read_metadata(backup1) - restore_func(backup1, volume_id, metadata, volume_file, backup) + restore_handle.add_backup(backup1, metadata, backup, volume_id) volume_meta = metadata.get('volume_meta', None) try: @@ -802,6 +726,8 @@ def restore(self, backup, volume_id, volume_file): LOG.error(msg) raise exception.BackupOperationError(msg) + restore_handle.finish_restore() + LOG.debug('restore %(backup_id)s to %(volume_id)s finished.', {'backup_id': backup_id, 'volume_id': volume_id}) @@ -836,3 +762,254 @@ def delete_backup(self, backup): eventlet.sleep(0) LOG.debug('delete %s finished.', backup['id']) + + +class BackupRestoreHandle(object, metaclass=abc.ABCMeta): + """Class used to reconstruct a backup from chunks.""" + def __init__(self, chunked_driver, volume_id, volume_file): + self._driver = chunked_driver + self._volume_id = volume_id + self._volume_file = volume_file + self._segments = [] + self._object_readers = {} + self._idx = -1 + + @abc.abstractmethod + def add_backup(self, backup, metadata, requested_backup, volume_id): + """This is called for each backup in the incremental backups chain.""" + return + + def finish_restore(self): + # SAP + # hacking into VmdkWriteHandle to set the correct file size. + # VMDKs are stream optimized, being smaller than the actual + # volume. Setting the correct vmdk_size in VmdkWriteHandle + # allows it to properly validate the upload progress, + # otherwise the restore would fail with 'incomplete transfer'. + if hasattr(self._volume_file, 'set_size'): + file_size = sum([s.length for s in self._segments]) + LOG.debug("Setting vmdk contents size to %s for restore", + file_size) + self._volume_file.set_size(file_size) + + for segment in self._segments: + LOG.debug('restoring object. backup: %(backup_id)s, ' + 'container: %(container)s, object name: ' + '%(object_name)s, volume: %(volume_id)s.', + { + 'backup_id': segment.obj['backup_id'], + 'container': segment.obj['container'], + 'object_name': segment.obj['name'], + 'volume_id': self._volume_id, + }) + self._idx += 1 + # write the segment bytes to the file + self._volume_file.write(self._read_segment(segment)) + + # force flush every write to avoid long blocking write on close + self._volume_file.flush() + + # Be tolerant to IO implementations that do not support fileno() + try: + fileno = self._volume_file.fileno() + except IOError: + LOG.info("volume_file does not support fileno() so skipping " + "fsync()") + else: + os.fsync(fileno) + + # Restoring a backup to a volume can take some time. Yield so other + # threads can run, allowing for among other things the service + # status to be updated + if hasattr(self._volume_file, '_get_progress'): + LOG.debug("progress? %(progress)s '%(volume_file)s'", + { + 'progress': self._volume_file._get_progress(), + 'volume_file': str(self._volume_file) + }) + eventlet.sleep(0) + + def _read_segment(self, segment): + """Reads the bytes of a segment""" + buff_reader = self._get_reader(segment) + # seek inside the backup chunk containing this segment + offset_diff = segment.offset - segment.obj['offset'] + buff_reader.seek(offset_diff) + # read the segment's length from the chunk + data = buff_reader.read(segment.length) + eventlet.tpool.execute(self._clear_reader, segment) + return data + + def _get_reader(self, segment): + """Keeps an internal cache of object readers. + + Avoids calling the storage backend multiple times for objects which + have been split into multiple segments due to merging. + """ + obj_name = segment.obj['name'] + obj_reader = self._object_readers.get(obj_name) + if not obj_reader: + obj_reader = self._get_new_reader(segment) + self._object_readers[obj_name] = obj_reader + return obj_reader + + def _get_new_reader(self, segment): + with self._driver._get_object_reader( + segment.obj['container'], + segment.obj['name'], + extra_metadata=segment.obj['extra_metadata']) \ + as reader: + return io.BytesIO(self._get_raw_bytes(reader, segment.obj)) + + def _get_raw_bytes(self, reader, obj): + """Get the bytes of a backup chunk, decompressing if needed""" + compression_algorithm = obj['compression'] + decompressor = self._driver._get_compressor(compression_algorithm) + if decompressor is not None: + LOG.debug('decompressing data using %s algorithm', + compression_algorithm) + return decompressor.decompress(reader.read()) + return reader.read() + + def _clear_reader(self, segment): + """Clear the object reader for a segment, if needed. + + If there is no further segment for the same object, we close and + remove the corresponding object reader, freeing up the memory. + """ + obj_name = segment.obj['name'] + for _segment in self._segments[self._idx + 1:]: + if obj_name == _segment.obj['name']: + return + self._object_readers[obj_name].close() + del self._object_readers[obj_name] + # See hint about https://github.com/eventlet/eventlet/issues/432 + # at the top why not to log here. May be fixed in oslo.log 5.0.1 + # LOG.debug("Cleared reader for object %s", segment.obj['name']) + + def add_object(self, metadata_object): + """Merges a backup chunk over the self._segments list. + + The backup chunks are expected to come in order. + :param metadata_object: the backup chunk + """ + # make a copy because we will modify it later + alt_obj = metadata_object.copy() + found = False + idx = 0 + while idx < len(self._segments): + segment = self._segments[idx] + offset = alt_obj['offset'] + length = alt_obj['length'] + end = offset + length + + # the object can be merged with this segment + if segment.offset <= offset < segment.end: + found = True + # remove the segment from the list, we're going to re-add + # only parts of it to the list or nothing at all + self._segments.pop(idx) + + # if the object starts after this segment's offset, then we + # keep the beginning of this segment + diff = offset - segment.offset + if diff > 0: + self._segments.insert(idx, + Segment.of(segment, + length=diff)) + idx += 1 + + # if the object ends before this segment's end, then we keep + # the last part of this segment, otherwise we don't + diff = segment.end - end + if diff > 0: + self._segments.insert(idx, Segment(alt_obj)) + idx += 1 + self._segments.insert(idx, + Segment.of(segment, + length=diff, + offset=end)) + idx += 1 + else: + self._segments.insert(idx, + Segment(alt_obj, + length=length + diff)) + idx += 1 + # if there is nothing left from this object, we're done + if diff == 0: + break + # if there is something left from this object, continue + # merging it over the next segment + alt_obj['offset'] = segment.end + alt_obj['length'] = abs(diff) + else: + idx += 1 + + # we did not find a segment which can be merged with this object, + # so we're adding this object straight to the list, just as it is. + if not found: + self._segments.append(Segment(alt_obj)) + + +class BackupRestoreHandleV1(BackupRestoreHandle): + """Handles restoring of V1 backups.""" + + def add_backup(self, backup, metadata, requested_backup, volume_id): + """Processes a v1 volume backup for being restored.""" + metadata_objects = metadata['objects'] + metadata_object_names = [] + for obj in metadata_objects: + metadata_object_names.extend(obj.keys()) + LOG.debug('metadata_object_names = %s.', metadata_object_names) + prune_list = [self._driver._metadata_filename(backup), + self._driver._sha256_filename(backup)] + object_names = [object_name for object_name in + self._driver._generate_object_names(backup) + if object_name not in prune_list] + if sorted(object_names) != sorted(metadata_object_names): + err = _('restore_backup aborted, actual object list ' + 'does not match object list stored in metadata.') + raise exception.InvalidBackup(reason=err) + + for metadata_object in metadata_objects: + with requested_backup.as_read_deleted(): + requested_backup.refresh() + if requested_backup.status != fields.BackupStatus.RESTORING: + raise exception.BackupRestoreCancel(back_id=backup.id, + vol_id=volume_id) + object_name, obj = list(metadata_object.items())[0] + # keep the information needed to read the object from the + # storage backend + obj['name'] = object_name + obj['backup_id'] = backup['id'] + obj['container'] = backup['container'] + obj['extra_metadata'] = metadata.get('extra_metadata') + + self.add_object(obj) + + +class Segment(object): + """Class being used to represent a segment of a backup object (chunk). + + It helps keeping track of multiple segments of the same chunk, in the + context of using only a few parts of a chunk for restoring incremental + backups. + + :param obj: The original backup chunk this segment belongs to + :param offset: (optional) offset where this segment starts. Default is + obj['offset']. It must be relative to the backup file. + :param length: (optional) length of this segment + """ + + def __init__(self, obj, offset=None, length=None): + self.obj = obj + self.offset = obj['offset'] if offset is None else offset + self.length = obj['length'] if length is None else length + self.end = self.offset + self.length + + @staticmethod + def of(segment, offset=None, length=None): + """Returns a new segment with different offset and/or length.""" + return Segment(segment.obj, + segment.offset if offset is None else offset, + segment.length if length is None else length) diff --git a/cinder/backup/manager.py b/cinder/backup/manager.py index 1d58c73d3a4..2196f20d237 100644 --- a/cinder/backup/manager.py +++ b/cinder/backup/manager.py @@ -45,6 +45,7 @@ from oslo_utils import importutils from oslo_utils import timeutils +from cinder import action_track from cinder.backup import rpcapi as backup_rpcapi from cinder import context from cinder import exception @@ -58,6 +59,7 @@ from cinder.volume import rpcapi as volume_rpcapi from cinder.volume import volume_utils + LOG = logging.getLogger(__name__) backup_manager_opts = [ @@ -330,6 +332,7 @@ def _cleanup_temp_volumes_snapshots_when_backup_created( if backup.temp_snapshot_id: self._delete_temp_snapshot(ctxt, backup) + @action_track.track_decorator(action_track.ACTION_VOLUME_BACKUP) @utils.limit_operations def create_backup(self, context, backup): """Create volume backups using configured backup service.""" @@ -339,7 +342,6 @@ def create_backup(self, context, backup): snapshot = objects.Snapshot.get_by_id( context, snapshot_id) if snapshot_id else None previous_status = volume.get('previous_status', None) - updates = {} if snapshot_id: log_message = ('Create backup started, backup: %(backup_id)s ' 'volume: %(volume_id)s snapshot: %(snapshot_id)s.' @@ -351,7 +353,10 @@ def create_backup(self, context, backup): 'volume: %(volume_id)s.' % {'backup_id': backup.id, 'volume_id': volume_id}) - LOG.info(log_message) + action_track.track( + context, action_track.ACTION_VOLUME_BACKUP, + volume, log_message + ) self._notify_about_backup_usage(context, backup, "create.start") @@ -396,7 +401,12 @@ def create_backup(self, context, backup): backup.service = self.driver_name backup.save() - updates = self._run_backup(context, backup, volume) + + # Backup is done in 3 phases. + # _start_backup + # continue_backup + # _finish_backup + self._start_backup(context, backup, volume) except Exception as err: with excutils.save_and_reraise_exception(): if snapshot_id: @@ -409,41 +419,16 @@ def create_backup(self, context, backup): 'previous_status': 'error_backing-up'}) volume_utils.update_backup_error(backup, str(err)) - # Restore the original status. - if snapshot_id: - self.db.snapshot_update( - context, snapshot_id, - {'status': fields.SnapshotStatus.AVAILABLE}) - else: - self.db.volume_update(context, volume_id, - {'status': previous_status, - 'previous_status': 'backing-up'}) - - # _run_backup method above updated the status for the backup, so it - # will reflect latest status, even if it is deleted - completion_msg = 'finished' - if backup.status in (fields.BackupStatus.DELETING, - fields.BackupStatus.DELETED): - completion_msg = 'aborted' - else: - backup.status = fields.BackupStatus.AVAILABLE - backup.size = volume['size'] - - if updates: - backup.update(updates) - backup.save() + @volume_utils.trace + def _start_backup(self, context, backup, volume): + """This starts the backup process. - # Handle the num_dependent_backups of parent backup when child - # backup has created successfully. - if backup.parent_id: - parent_backup = objects.Backup.get_by_id(context, - backup.parent_id) - parent_backup.num_dependent_backups += 1 - parent_backup.save() - LOG.info('Create backup %s. backup: %s.', completion_msg, backup.id) - self._notify_about_backup_usage(context, backup, "create.end") + First we have to get the backup device from the volume manager. + This can take a long time to complete. Once the volume manager + is done creating/getting the backup device, we get a callback + to complete the process of backing up the volume. - def _run_backup(self, context, backup, volume): + """ # Save a copy of the encryption key ID in case the volume is deleted. if (volume.encryption_key_id is not None and backup.encryption_key_id is None): @@ -453,22 +438,55 @@ def _run_backup(self, context, backup, volume): volume.encryption_key_id) backup.save() + # This is an async call to the volume manager. We will get a + # callback from the volume manager to continue once it's done. + action_track.track( + context, action_track.ACTION_VOLUME_BACKUP, + volume, "calling volume_rpcapi.get_backup_device()" + ) + self.volume_rpcapi.get_backup_device(context, backup, volume) + + @volume_utils.trace + @action_track.track_decorator(action_track.ACTION_VOLUME_BACKUP) + def continue_backup(self, context, backup, backup_device): + """This is the callback from the volume manager to continue. + + If something went wrong on the volume manager getting/creating + the backup_device, the backup_device will be None. + """ + volume_id = backup.volume_id + volume = objects.Volume.get_by_id(context, volume_id) + snapshot_id = backup.snapshot_id + snapshot = objects.Snapshot.get_by_id( + context, snapshot_id) if snapshot_id else None + previous_status = volume.get('previous_status', None) + backup_service = self.service(context) properties = volume_utils.brick_get_connector_properties() - # NOTE(geguileo): Not all I/O disk operations properly do greenthread - # context switching and may end up blocking the greenthread, so we go - # with native threads proxy-wrapping the device file object. + updates = {} try: - backup_device = self.volume_rpcapi.get_backup_device(context, - backup, - volume) + if not backup_device: + # The volume manager didn't provide a backup_device + # due to something going wrong. So we raise here and + # cleanup the volume state and the backup state to error. + raise exception.BackupOperationError("Failed to get backup " + "device from driver.") + + action_track.track( + context, action_track.ACTION_VOLUME_BACKUP, + volume, "_attach_device()" + ) attach_info = self._attach_device(context, backup_device.device_obj, properties, backup_device.is_snapshot) try: + action_track.track( + context, action_track.ACTION_VOLUME_BACKUP, + volume, "call backup_service.backup()" + ) device_path = attach_info['device']['path'] if (isinstance(device_path, str) and not os.path.isdir(device_path)): @@ -491,12 +509,74 @@ def _run_backup(self, context, backup, volume): backup_device.device_obj, properties, backup_device.is_snapshot, force=True, ignore_errors=True) + except Exception as err: + with excutils.save_and_reraise_exception(): + if snapshot_id: + snapshot.status = fields.SnapshotStatus.AVAILABLE + snapshot.save() + else: + self.db.volume_update( + context, volume_id, + {'status': previous_status, + 'previous_status': 'error_backing-up'}) + volume_utils.update_backup_error(backup, str(err)) finally: with backup.as_read_deleted(): backup.refresh() self._cleanup_temp_volumes_snapshots_when_backup_created( context, backup) - return updates + + action_track.track( + context, action_track.ACTION_VOLUME_BACKUP, + volume, "finish backup!" + ) + self._finish_backup(context, backup, volume, updates) + + @action_track.track_decorator(action_track.ACTION_VOLUME_BACKUP) + @volume_utils.trace + def _finish_backup(self, context, backup, volume, updates): + volume_id = backup.volume_id + snapshot_id = backup.snapshot_id + previous_status = volume.get('previous_status', None) + + # Restore the original status. + if snapshot_id: + self.db.snapshot_update( + context, snapshot_id, + {'status': fields.SnapshotStatus.AVAILABLE}) + else: + self.db.volume_update(context, volume_id, + {'status': previous_status, + 'previous_status': 'backing-up'}) + + # continue_backup method above updated the status for the backup, so it + # will reflect latest status, even if it is deleted + completion_msg = 'finished' + if backup.status in (fields.BackupStatus.DELETING, + fields.BackupStatus.DELETED): + completion_msg = 'aborted' + else: + backup.status = fields.BackupStatus.AVAILABLE + backup.size = volume['size'] + + if updates: + backup.update(updates) + backup.save() + + # Handle the num_dependent_backups of parent backup when child + # backup has created successfully. + if backup.parent_id: + parent_backup = objects.Backup.get_by_id(context, + backup.parent_id) + parent_backup.num_dependent_backups += 1 + parent_backup.save() + LOG.info('Create backup %s. backup: %s.', completion_msg, backup.id) + action_track.track( + context, action_track.ACTION_VOLUME_BACKUP, + volume, + f'Create backup {completion_msg}. backup: {backup.id}.' + ) + self._notify_about_backup_usage(context, backup, "create.end") def _is_our_backup(self, backup): # Accept strings and Service OVO @@ -520,12 +600,17 @@ def _is_our_backup(self, backup): return False + @action_track.track_decorator(action_track.ACTION_BACKUP_RESTORE) @utils.limit_operations def restore_backup(self, context, backup, volume_id): """Restore volume backups from configured backup service.""" - LOG.info('Restore backup started, backup: %(backup_id)s ' - 'volume: %(volume_id)s.', - {'backup_id': backup.id, 'volume_id': volume_id}) + msg = ('Restore backup started, backup: %(backup_id)s ' + 'volume: %(volume_id)s.' % + {'backup_id': backup.id, 'volume_id': volume_id}) + action_track.track( + context, action_track.ACTION_BACKUP_RESTORE, + backup, msg + ) volume = objects.Volume.get_by_id(context, volume_id) self._notify_about_backup_usage(context, backup, "restore.start") @@ -561,13 +646,17 @@ def restore_backup(self, context, backup, volume_id): raise exception.InvalidBackup(reason=err) if volume['size'] > backup['size']: - LOG.info('Volume: %(vol_id)s, size: %(vol_size)d is ' - 'larger than backup: %(backup_id)s, ' - 'size: %(backup_size)d, continuing with restore.', - {'vol_id': volume['id'], - 'vol_size': volume['size'], - 'backup_id': backup['id'], - 'backup_size': backup['size']}) + msg = ('Volume: %(vol_id)s, size: %(vol_size)d is ' + 'larger than backup: %(backup_id)s, ' + 'size: %(backup_size)d, continuing with restore.' % + {'vol_id': volume['id'], + 'vol_size': volume['size'], + 'backup_id': backup['id'], + 'backup_size': backup['size']}) + action_track.track( + context, action_track.ACTION_BACKUP_RESTORE, + backup, msg + ) if not self._is_our_backup(backup): err = _('Restore backup aborted, the backup service currently' @@ -588,6 +677,10 @@ def restore_backup(self, context, backup, volume_id): self._run_restore(context, backup, volume) except exception.BackupRestoreCancel: canceled = True + action_track.track( + context, action_track.ACTION_BACKUP_RESTORE, + backup, "restore was cancelled", loglevel=logging.ERROR + ) except Exception: with excutils.save_and_reraise_exception(): self.db.volume_update( @@ -620,26 +713,42 @@ def restore_backup(self, context, backup, volume_id): volume.save() backup.status = fields.BackupStatus.AVAILABLE backup.save() - LOG.info('%(result)s restoring backup %(backup_id)s to volume ' - '%(volume_id)s.', - {'result': 'Canceled' if canceled else 'Finished', - 'backup_id': backup.id, - 'volume_id': volume_id}) + msg = ('%(result)s restoring backup %(backup_id)s to volume ' + '%(volume_id)s.' % + {'result': 'Canceled' if canceled else 'Finished', + 'backup_id': backup.id, + 'volume_id': volume_id}) self._notify_about_backup_usage(context, backup, "restore.end") + action_track.track( + context, action_track.ACTION_BACKUP_RESTORE, + backup, msg + ) def _run_restore(self, context, backup, volume): orig_key_id = volume.encryption_key_id backup_service = self.service(context) + action_track.track( + context, action_track.ACTION_BACKUP_RESTORE, + backup, "_run_restore called" + ) properties = volume_utils.brick_get_connector_properties() secure_enabled = ( self.volume_rpcapi.secure_file_operations_enabled(context, volume)) + action_track.track( + context, action_track.ACTION_BACKUP_RESTORE, + backup, "_attach_device" + ) attach_info = self._attach_device(context, volume, properties) # NOTE(geguileo): Not all I/O disk operations properly do greenthread # context switching and may end up blocking the greenthread, so we go # with native threads proxy-wrapping the device file object. + action_track.track( + context, action_track.ACTION_BACKUP_RESTORE, + backup, "call backup_service.restore" + ) try: device_path = attach_info['device']['path'] open_mode = 'rb+' if os.name == 'nt' else 'wb' @@ -661,9 +770,14 @@ def _run_restore(self, context, backup, volume): except exception.BackupRestoreCancel: raise except Exception: - LOG.exception('Restoring backup %(backup_id)s to volume ' - '%(volume_id)s failed.', {'backup_id': backup.id, - 'volume_id': volume.id}) + msg = ('Restoring backup %(backup_id)s to volume ' + '%(volume_id)s failed.' % + {'backup_id': backup.id, 'volume_id': volume.id}) + LOG.exception(msg) + action_track.track( + context, action_track.ACTION_BACKUP_RESTORE, + backup, msg, loglevel=logging.ERROR + ) raise finally: self._detach_device(context, attach_info, volume, properties, @@ -711,6 +825,7 @@ def _run_restore(self, context, backup, volume): 'matches encryption key ID in backup %(backup_id)s.', {'volume_id': volume.id, 'backup_id': backup.id}) + @action_track.track_decorator(action_track.ACTION_VOLUME_BACKUP_DELETE) def delete_backup(self, context, backup): """Delete volume backup from configured backup service.""" LOG.info('Delete backup started, backup: %s.', backup.id) @@ -725,12 +840,20 @@ def delete_backup(self, context, backup): % {'expected_status': expected_status, 'actual_status': actual_status} volume_utils.update_backup_error(backup, err) + action_track.track( + context, action_track.ACTION_VOLUME_BACKUP_DELETE, + backup, err, loglevel=logging.ERROR + ) raise exception.InvalidBackup(reason=err) if backup.service and not self.is_working(): err = _('Delete backup is aborted due to backup service is down.') status = fields.BackupStatus.ERROR_DELETING volume_utils.update_backup_error(backup, err, status) + action_track.track( + context, action_track.ACTION_VOLUME_BACKUP_DELETE, + backup, err, loglevel=logging.ERROR + ) raise exception.InvalidBackup(reason=err) if not self._is_our_backup(backup): @@ -741,6 +864,10 @@ def delete_backup(self, context, backup): % {'configured_service': self.driver_name, 'backup_service': backup.service} volume_utils.update_backup_error(backup, err) + action_track.track( + context, action_track.ACTION_VOLUME_BACKUP_DELETE, + backup, err, loglevel=logging.ERROR + ) raise exception.InvalidBackup(reason=err) if backup.service: @@ -750,6 +877,10 @@ def delete_backup(self, context, backup): except Exception as err: with excutils.save_and_reraise_exception(): volume_utils.update_backup_error(backup, str(err)) + action_track.track( + context, action_track.ACTION_VOLUME_BACKUP_DELETE, + backup, str(err), loglevel=logging.ERROR + ) # Get reservations try: @@ -785,7 +916,11 @@ def delete_backup(self, context, backup): QUOTAS.commit(context, reservations, project_id=backup.project_id) - LOG.info('Delete backup finished, backup %s deleted.', backup.id) + msg = ('Delete backup finished, backup %s deleted.' % backup.id) + action_track.track( + context, action_track.ACTION_VOLUME_BACKUP_DELETE, + backup, msg + ) self._notify_about_backup_usage(context, backup, "delete.end") def _notify_about_backup_usage(self, @@ -947,6 +1082,9 @@ def import_record(self, LOG.info('Import record id %s metadata from driver ' 'finished.', backup.id) + @action_track.track_decorator( + action_track.ACTION_VOLUME_BACKUP_RESET_STATUS + ) def reset_status(self, context, backup, status): """Reset volume backup status. @@ -1013,15 +1151,24 @@ def _attach_volume(self, context, volume, properties): return self._connect_device(conn) except Exception: with excutils.save_and_reraise_exception(): + action_track.track( + context, action_track.ACTION_VOLUME_BACKUP, + volume, "Failed volume_rpcapi.initialize_connection", + loglevel=logging.ERROR + ) try: self.volume_rpcapi.terminate_connection(context, volume, properties, force=True) except Exception: - LOG.warning("Failed to terminate the connection " - "of volume %(volume_id)s, but it is " - "acceptable.", - {'volume_id': volume.id}) + msg = ("Failed to terminate the connection " + "of volume %(volume_id)s, but it is " + "acceptable." % + {'volume_id': volume.id}) + action_track.track( + context, action_track.ACTION_VOLUME_BACKUP, + volume, msg + ) def _attach_snapshot(self, ctxt, snapshot, properties): """Attach a snapshot.""" diff --git a/cinder/backup/rpcapi.py b/cinder/backup/rpcapi.py index 981526fcc86..bf9856c487d 100644 --- a/cinder/backup/rpcapi.py +++ b/cinder/backup/rpcapi.py @@ -47,9 +47,10 @@ class BackupAPI(rpc.RPCAPI): 2.0 - Remove 1.x compatibility 2.1 - Adds set_log_levels and get_log_levels 2.2 - Adds publish_service_capabilities + 2.3 - Adds continue_backup call """ - RPC_API_VERSION = '2.2' + RPC_API_VERSION = '2.3' RPC_DEFAULT_VERSION = '2.0' TOPIC = constants.BACKUP_TOPIC BINARY = 'cinder-backup' @@ -59,6 +60,12 @@ def create_backup(self, ctxt, backup): cctxt = self._get_cctxt(server=backup.host) cctxt.cast(ctxt, 'create_backup', backup=backup) + def continue_backup(self, ctxt, backup, backup_device): + LOG.debug("continue_backup in rpcapi backup_id %s", backup.id) + cctxt = self._get_cctxt(server=backup.host) + cctxt.cast(ctxt, 'continue_backup', backup=backup, + backup_device=backup_device) + def restore_backup(self, ctxt, backup_host, backup, volume_id): LOG.debug("restore_backup in rpcapi backup_id %s", backup.id) cctxt = self._get_cctxt(server=backup_host) diff --git a/cinder/db/api.py b/cinder/db/api.py index fa792a1cc13..56eb69b9ebc 100644 --- a/cinder/db/api.py +++ b/cinder/db/api.py @@ -281,6 +281,12 @@ def volume_get_all(context, marker=None, limit=None, sort_keys=None, offset=offset) +def get_hosts_by_volume_metadata(key, value, filters=None): + """Returns the host with the most volumes matching volume metadata.""" + return IMPL.get_hosts_by_volume_metadata(key, value, + filters=filters) + + def calculate_resource_count(context, resource_type, filters): return IMPL.calculate_resource_count(context, resource_type, filters) diff --git a/cinder/db/migration.py b/cinder/db/migration.py index 4f4e1ca695b..0485b1f2445 100644 --- a/cinder/db/migration.py +++ b/cinder/db/migration.py @@ -26,7 +26,7 @@ from cinder.db.sqlalchemy import api as db_api -INIT_VERSION = 122 +INIT_VERSION = 119 _IMPL = None _LOCK = threading.Lock() diff --git a/cinder/db/sqlalchemy/api.py b/cinder/db/sqlalchemy/api.py index 57b98a73c7e..7ecee7e4e3a 100644 --- a/cinder/db/sqlalchemy/api.py +++ b/cinder/db/sqlalchemy/api.py @@ -2176,6 +2176,35 @@ def volume_get_all(context, marker=None, limit=None, sort_keys=None, return query.all() +def get_hosts_by_volume_metadata(meta_key, meta_value, filters=None): + session = get_session() + count_label = func.count().label("n") + query = session.query( + func.substring_index(models.Volume.host, '@', 1).label("h"), + count_label + ).join( + models.VolumeMetadata, + models.VolumeMetadata.volume_id == models.Volume.id + ).filter( + models.VolumeMetadata.key == meta_key, + models.VolumeMetadata.value == meta_value, + models.Volume.deleted == 0, + models.Volume.host.isnot(None) + ) + + if filters: + az = filters.get('availability_zone') + if az: + query = query.filter( + models.Volume.availability_zone == az) + + query = query.group_by("h")\ + .order_by(desc(count_label)) + + with session.begin(): + return query.all() + + @require_context def get_volume_summary(context, project_only, filters=None): """Retrieves all volumes summary. diff --git a/cinder/db/sqlalchemy/migrate_repo/versions/123_cinder_init.py b/cinder/db/sqlalchemy/migrate_repo/versions/120_cinder_init.py similarity index 99% rename from cinder/db/sqlalchemy/migrate_repo/versions/123_cinder_init.py rename to cinder/db/sqlalchemy/migrate_repo/versions/120_cinder_init.py index bdca01dc19c..d007795b1e1 100644 --- a/cinder/db/sqlalchemy/migrate_repo/versions/123_cinder_init.py +++ b/cinder/db/sqlalchemy/migrate_repo/versions/120_cinder_init.py @@ -410,6 +410,7 @@ def define_tables(meta): Column('until_refresh', Integer(), nullable=True), Index('quota_usage_project_resource_idx', 'project_id', 'resource'), + UniqueConstraint('project_id', 'resource', 'deleted'), mysql_engine='InnoDB', mysql_charset='utf8', ) @@ -523,7 +524,6 @@ def define_tables(meta): Column('salt', String(255)), Column('crypt_hash', String(255)), Column('expires_at', DateTime(timezone=False)), - Column('no_snapshots', Boolean, default=False), mysql_engine='InnoDB', mysql_charset='utf8' ) diff --git a/cinder/db/sqlalchemy/migrate_repo/versions/121_placeholder.py b/cinder/db/sqlalchemy/migrate_repo/versions/121_placeholder.py new file mode 100644 index 00000000000..9609bdf16ae --- /dev/null +++ b/cinder/db/sqlalchemy/migrate_repo/versions/121_placeholder.py @@ -0,0 +1,22 @@ +# 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. + +# This is a placeholder for Queens backports. +# Do not use this number for new Rocky work. New work starts after +# all the placeholders. +# +# See this for more information: +# http://lists.openstack.org/pipermail/openstack-dev/2013-March/006827.html + + +def upgrade(migrate_engine): + pass diff --git a/cinder/db/sqlalchemy/migrate_repo/versions/122_placeholder.py b/cinder/db/sqlalchemy/migrate_repo/versions/122_placeholder.py new file mode 100644 index 00000000000..9609bdf16ae --- /dev/null +++ b/cinder/db/sqlalchemy/migrate_repo/versions/122_placeholder.py @@ -0,0 +1,22 @@ +# 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. + +# This is a placeholder for Queens backports. +# Do not use this number for new Rocky work. New work starts after +# all the placeholders. +# +# See this for more information: +# http://lists.openstack.org/pipermail/openstack-dev/2013-March/006827.html + + +def upgrade(migrate_engine): + pass diff --git a/cinder/db/sqlalchemy/migrate_repo/versions/123_add_transfer_no_snapshots.py b/cinder/db/sqlalchemy/migrate_repo/versions/123_add_transfer_no_snapshots.py new file mode 100644 index 00000000000..4cee7fe793b --- /dev/null +++ b/cinder/db/sqlalchemy/migrate_repo/versions/123_add_transfer_no_snapshots.py @@ -0,0 +1,21 @@ +# 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 sqlalchemy import Boolean, Column, MetaData, Table + + +def upgrade(migrate_engine): + """Add the no_snapshots column to the transfers table.""" + meta = MetaData(bind=migrate_engine) + transfers = Table('transfers', meta, autoload=True) + if not hasattr(transfers.c, 'no_snapshots'): + transfers.create_column(Column('no_snapshots', Boolean, default=False)) diff --git a/cinder/db/sqlalchemy/migrate_repo/versions/141_add_quota_usage_unique_constraint.py b/cinder/db/sqlalchemy/migrate_repo/versions/141_add_quota_usage_unique_constraint.py index 81ed7bb2d50..b5559b8d689 100644 --- a/cinder/db/sqlalchemy/migrate_repo/versions/141_add_quota_usage_unique_constraint.py +++ b/cinder/db/sqlalchemy/migrate_repo/versions/141_add_quota_usage_unique_constraint.py @@ -31,6 +31,12 @@ def upgrade(migrate_engine): if not hasattr(quota_usages.c, 'race_preventer'): quota_usages.create_column(Column('race_preventer', Boolean, nullable=True)) + # SAP drop the existing constraint + unique_SAP = constraint.UniqueConstraint( + 'project_id', 'resource', 'deleted', + table=quota_usages) + unique_SAP.drop(engine=migrate_engine) + unique = constraint.UniqueConstraint( 'project_id', 'resource', 'race_preventer', table=quota_usages) diff --git a/cinder/exception.py b/cinder/exception.py index 9b6cd1534f8..5bfa7b2ff6d 100644 --- a/cinder/exception.py +++ b/cinder/exception.py @@ -602,6 +602,10 @@ class NoValidBackend(CinderException): message = _("No valid backend was found. %(reason)s") +class InvalidConnectionCapabilities(CinderException): + message = _("Invalid connection capabilities. %(reason)s") + + class QuotaError(CinderException): message = _("Quota exceeded: code=%(code)s") code = 413 @@ -694,6 +698,10 @@ class InvalidConnectorException(VolumeDriverException): message = _("Connector doesn't have required information: %(missing)s") +class ConnectorRejected(VolumeDriverException): + message = _("Connector can't be used with this driver anymore. %(reason)s") + + class GlanceMetadataExists(Invalid): message = _("Glance metadata cannot be updated, key %(key)s" " exists for volume id %(volume_id)s") diff --git a/cinder/group/api.py b/cinder/group/api.py index 29c138e2f34..852f9e25d93 100644 --- a/cinder/group/api.py +++ b/cinder/group/api.py @@ -254,7 +254,10 @@ def create_from_src(self, context, name, description=None, # 'volume_properties' as scheduler's filter logic are all designed # based on this attribute. kwargs = {'group_id': group.id, - 'volume_properties': objects.VolumeProperties(size=size)} + 'volume_properties': objects.VolumeProperties( + size=size, + project_id=group.project_id + )} host = group.resource_backend if not host or not self.scheduler_rpcapi.validate_host_capacity( diff --git a/cinder/interface/volume_driver.py b/cinder/interface/volume_driver.py index e3a7640c5e9..27cd75fe70a 100644 --- a/cinder/interface/volume_driver.py +++ b/cinder/interface/volume_driver.py @@ -138,6 +138,9 @@ def get_volume_stats(self, refresh=False): * online_extend_support (Boolean) Whether the backend supports in-use volume extend or not. Defaults to True. + * connection_capabilities (list) + A list of capabilities that need to match the connector's + capabilities when a connection is being initialized. The returned dict may also contain a list, "pools", which has a similar dict for each pool being used with the backend. diff --git a/cinder/manager.py b/cinder/manager.py index 6689b3d0c34..6a02abf901f 100644 --- a/cinder/manager.py +++ b/cinder/manager.py @@ -194,7 +194,8 @@ def update_service_capabilities(self, capabilities): def _publish_service_capabilities(self, context): """Pass data back to the scheduler at a periodic interval.""" if self.last_capabilities: - LOG.debug('Notifying Schedulers of capabilities ...') + LOG.debug('Notifying Schedulers of capabilities for %(host)s...', + {'host': self.host}) self.scheduler_rpcapi.update_service_capabilities( context, self.service_name, diff --git a/cinder/objects/snapshot.py b/cinder/objects/snapshot.py index 844c51ca5ca..114184a4b55 100644 --- a/cinder/objects/snapshot.py +++ b/cinder/objects/snapshot.py @@ -16,6 +16,7 @@ from oslo_utils import versionutils from oslo_versionedobjects import fields +from cinder.api import common from cinder import db from cinder import exception from cinder.i18n import _ @@ -28,6 +29,8 @@ CONF = cfg.CONF +SAP_HIDDEN_BACKEND_KEY = common.SAP_HIDDEN_METADATA_KEY + "_backend" + @base.CinderObjectRegistry.register class Snapshot(cleanable.CinderCleanableObject, base.CinderObject, diff --git a/cinder/opts.py b/cinder/opts.py index f0274559fd4..4e056eaeffc 100644 --- a/cinder/opts.py +++ b/cinder/opts.py @@ -56,6 +56,8 @@ from cinder.message import api as cinder_message_api from cinder import quota as cinder_quota from cinder.scheduler import driver as cinder_scheduler_driver +from cinder.scheduler.filters import shard_filter as \ + cinder_scheduler_filters_shardfilter from cinder.scheduler import host_manager as cinder_scheduler_hostmanager from cinder.scheduler import manager as cinder_scheduler_manager from cinder.scheduler import scheduler_options as \ @@ -219,6 +221,7 @@ def list_opts(): [cinder_api_middleware_auth.use_forwarded_for_opt], cinder_api_views_versions.versions_opts, cinder_backup_api.backup_opts, + cinder_backup_api.sap_backup_opts, cinder_backup_chunkeddriver.backup_opts, cinder_backup_driver.backup_opts, cinder_backup_drivers_ceph.service_opts, @@ -296,6 +299,10 @@ def list_opts(): cinder_zonemanager_drivers_cisco_ciscofczonedriver.cisco_opts, cinder_zonemanager_fczonemanager.zone_manager_opts, )), + ('keystone_group', + itertools.chain( + cinder_scheduler_filters_shardfilter.keystone_opts, + )), ('key_manager', itertools.chain( cinder_keymgr_confkeymgr.key_mgr_opts, diff --git a/cinder/policies/services.py b/cinder/policies/services.py index 7aee0cdc06e..8e44ddaa167 100644 --- a/cinder/policies/services.py +++ b/cinder/policies/services.py @@ -23,6 +23,7 @@ FAILOVER_POLICY = "volume:failover_host" FREEZE_POLICY = "volume:freeze_host" THAW_POLICY = "volume:thaw_host" +RECOUNT_STATS_POLICY = "volume:recount_host_stats" services_policies = [ policy.DocumentedRuleDefault( @@ -76,6 +77,16 @@ 'path': '/os-services/failover_host' } ]), + policy.DocumentedRuleDefault( + name=RECOUNT_STATS_POLICY, + check_str=base.RULE_ADMIN_API, + description="Recount host stats allocated capacity", + operations=[ + { + 'method': 'PUT', + 'path': '/os-services/recount_host_stats' + } + ]), ] diff --git a/cinder/scheduler/driver.py b/cinder/scheduler/driver.py index fe92905bee2..8e0cae99f02 100644 --- a/cinder/scheduler/driver.py +++ b/cinder/scheduler/driver.py @@ -150,6 +150,10 @@ def schedule_create_group(self, context, group, raise NotImplementedError(_( "Must implement schedule_create_group")) + def find_backend_for_connector(self, context, connector, request_spec): + raise NotImplementedError(_("Must implement" + "find_backend_for_connector")) + def get_pools(self, context, filters): """Must override schedule method for scheduler to work.""" raise NotImplementedError(_( diff --git a/cinder/scheduler/filter_scheduler.py b/cinder/scheduler/filter_scheduler.py index 2f047d81aea..e8e971a25e6 100644 --- a/cinder/scheduler/filter_scheduler.py +++ b/cinder/scheduler/filter_scheduler.py @@ -111,6 +111,45 @@ def schedule_create_volume(self, context, request_spec, filter_properties): filter_properties, allow_reschedule=True) + def find_backend_for_connector(self, context, connector, request_spec, + filter_properties=None): + key = 'connection_capabilities' + if key not in connector: + raise exception.InvalidConnectionCapabilities( + reason=_("The connector doesn't contain a %s field.") % key) + + weighed_backends = self._get_weighted_candidates(context, request_spec, + filter_properties) + if not weighed_backends: + raise exception.NoValidBackend(reason=_("No weighed backends " + "available")) + connector_capabilities = set(connector[key]) + + def _backend_matches_connector(bck): + if key not in bck.obj.capabilities: + LOG.debug("Backend %(backend) doesn't contain %(key)s.", + {'backend': bck.obj.host, 'key': key}) + return False + backend_capabilities = set(bck.obj.capabilities.get(key)) + if connector_capabilities & backend_capabilities ==\ + connector_capabilities: + return True + LOG.debug("Requested %(key)s %(req)s not found in " + "%(host)s backend's %(key)s %(given)s.", + {'key': key, 'req': connector_capabilities, + 'host': bck.obj.host, 'given': backend_capabilities}) + return False + + weighed_backends = [b for b in weighed_backends if + _backend_matches_connector(b)] + + if not weighed_backends: + raise exception.NoValidBackend( + reason=_("No backend matched the given connector.")) + + top_backend = self._choose_top_backend(weighed_backends, request_spec) + return top_backend.obj + def backend_passes_filters(self, context, backend, request_spec, filter_properties): """Check if the specified backend passes the filters.""" diff --git a/cinder/scheduler/filters/capacity_filter.py b/cinder/scheduler/filters/capacity_filter.py index bd4cce839a3..38c3550c5fe 100644 --- a/cinder/scheduler/filters/capacity_filter.py +++ b/cinder/scheduler/filters/capacity_filter.py @@ -139,8 +139,8 @@ def backend_passes(self, backend_state, filter_properties): msg_args = {"grouping_name": backend_state.backend_id, "grouping": grouping, "requested": requested_size, - "available": virtual_free_space} - + "available": virtual_free_space, + "provisioning_type": factors["provisioned_type"]} # Only evaluate using max_over_subscription_ratio if # thin_provisioning_support is True. Check if the ratio of # provisioned capacity over total capacity has exceeded over @@ -160,13 +160,14 @@ def backend_passes(self, backend_state, filter_properties): "oversub_ratio": backend_state.max_over_subscription_ratio, "grouping": grouping, "grouping_name": backend_state.backend_id, + "provisioning_type": "thin", } LOG.warning( "Insufficient free space for thin provisioning. " "The ratio of provisioned capacity over total capacity " "%(provisioned_ratio).2f has exceeded the maximum over " "subscription ratio %(oversub_ratio).2f on %(grouping)s " - "%(grouping_name)s.", msg_args) + "%(grouping_name)s %(provisioning_type)s.", msg_args) return False else: # Thin provisioning is enabled and projected over-subscription @@ -180,13 +181,16 @@ def backend_passes(self, backend_state, filter_properties): if not res: LOG.warning("Insufficient free virtual space " "(%(available)sGB) to accommodate thin " - "provisioned %(requested)sGB volume on " - "%(grouping)s %(grouping_name)s.", msg_args) + "provisioned %(requested)sGB volume on" + " %(grouping)s %(grouping_name)s" + " %(provisioning_type)s.", + msg_args) else: LOG.debug("Space information for volume creation " "on %(grouping)s %(grouping_name)s " "(requested / avail): " - "%(requested)s/%(available)s", msg_args) + "%(requested)s/%(available)s" + " %(provisioning_type)s.", msg_args) return res elif thin and backend_state.thin_provisioning_support: LOG.warning("Filtering out %(grouping)s %(grouping_name)s " @@ -202,12 +206,13 @@ def backend_passes(self, backend_state, filter_properties): if virtual_free_space < requested_size: LOG.warning("Insufficient free space for volume creation " "on %(grouping)s %(grouping_name)s (requested / " - "avail): %(requested)s/%(available)s", - msg_args) + "avail): %(requested)s/%(available)s " + "%(provisioning_type)s", msg_args) return False - LOG.debug("Space information for volume creation " + LOG.debug("[SAP] Space information for volume creation " "on %(grouping)s %(grouping_name)s (requested / avail): " - "%(requested)s/%(available)s", msg_args) + "%(requested)s/%(available)s %(provisioning_type)s.", + msg_args) return True diff --git a/cinder/scheduler/filters/sap_affinity_filter.py b/cinder/scheduler/filters/sap_affinity_filter.py new file mode 100644 index 00000000000..5d4e3b75d7a --- /dev/null +++ b/cinder/scheduler/filters/sap_affinity_filter.py @@ -0,0 +1,192 @@ +# Copyright 2022, SAP SE +# All Rights Reserved. +# +# 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 oslo_log import log as logging +from oslo_utils import uuidutils + +from cinder.scheduler import filters +from cinder.volume import api as volume +from cinder.volume import volume_utils + + +LOG = logging.getLogger(__name__) + + +class SAPBackendFilter(filters.BaseBackendFilter): + + run_filter_once_per_request = False + + def __init__(self): + super().__init__() + self.volume_api = volume.API() + self.backend_fqdn_lookup = {} + + def _get_volumes(self, context, affinity_uuids, backend_state): + # We don't filter here on host. + filters = {'id': affinity_uuids, 'deleted': False} + return self.volume_api.get_all(context, filters=filters) + + def _affinity_volumes(self, backend_state, filter_properties): + context = filter_properties['context'] + scheduler_hints = filter_properties.get('scheduler_hints') or {} + affinity_uuids = scheduler_hints.get(self.hint_key, []) + + # scheduler hint verification: affinity_uuids can be a list of uuids + # or single uuid. The checks here is to make sure every single string + # in the list looks like a uuid, otherwise, this filter will fail to + # pass. Note that the filter does *NOT* ignore string doesn't look + # like a uuid, it is better to fail the request than serving it wrong. + if isinstance(affinity_uuids, list): + for uuid in affinity_uuids: + if uuidutils.is_uuid_like(uuid): + continue + else: + return False + elif uuidutils.is_uuid_like(affinity_uuids): + affinity_uuids = [affinity_uuids] + else: + # Not a list, not a string looks like uuid, don't pass it + # to DB for query to avoid potential risk. + return False + + if affinity_uuids: + return self._get_volumes(context, affinity_uuids, backend_state) + + def filter_all(self, filter_obj_list, filter_properties): + """Yield objects that pass the filter. + + Can be overridden in a subclass, if you need to base filtering + decisions on all objects. Otherwise, one can just override + _filter_one() to filter a single object. + + We override this so we can build a datastore -> backend fqdn lookup + for all the datastores. The filter will use it to test if the + volume's backend fqdn matches the passed in pool's fqdn. + """ + # reset the lookup for this request. It can change between requests. + self.backend_fqdn_lookup = {} + + # First we need to get all of the pools and populate a lookup for + # the datastore backend fqdn custom attributes, if any. + scheduler_hints = filter_properties.get('scheduler_hints') or {} + affinity_uuids = scheduler_hints.get(self.hint_key, []) + # filter_obj_list is a generator, so we need to copy + # each entry for looping again. + obj_list = [] + if affinity_uuids: + # Build the backend_fqdn_lookup, since we will need it this request + objs = list(filter_obj_list) + for obj in objs: + obj_list.append(obj) + caps = obj.capabilities + if ('custom_attributes' in caps and + 'netapp_fqdn' in caps['custom_attributes']): + datastore = volume_utils.extract_host(obj.host, + level='pool') + fqdn = caps['custom_attributes']['netapp_fqdn'] + self.backend_fqdn_lookup[datastore] = fqdn + else: + obj_list = filter_obj_list + + for obj in obj_list: + if self._filter_one(obj, filter_properties): + yield obj + + def _get_backend_fqdn(self, pool_name): + if pool_name in self.backend_fqdn_lookup: + return self.backend_fqdn_lookup[pool_name] + else: + return None + + +class SAPDifferentBackendFilter(SAPBackendFilter): + """Schedule volume on a different back-end from a set of volumes.""" + + hint_key = "different_host" + + def backend_passes(self, backend_state, filter_properties): + volumes = self._affinity_volumes(backend_state, filter_properties) + + # If we got no volumes, then no reason to check + if not volumes: + return True + + # Get the backend fqdn custom attribute for the volume + backend_datastore = volume_utils.extract_host(backend_state.host, + level='pool') + backend_fqdn = self._get_backend_fqdn(backend_datastore) + if not backend_fqdn: + # The datastore being filtered doesn't have a custom fqdn set + # Don't filter it out. + LOG.debug("Datastore {} has no fqdn", backend_datastore) + return True + + # extract the datastore from the host entries from + # the volumes (from affinity_uuids), then find the backend associated + # with each of those and then only allow the same netapp to pass + for vol in volumes: + volume_datastore = volume_utils.extract_host(vol.host, + level='pool') + volume_fqdn = self._get_backend_fqdn(volume_datastore) + if volume_fqdn: + if volume_fqdn == backend_fqdn: + LOG.debug("Volume FQDN matches {}", + backend_fqdn, resource=vol) + return False + + return True + + +class SAPSameBackendFilter(SAPBackendFilter): + """Schedule volume on the same back-end as another volume. + + This also ensures that if a backend has a custom attribute + that specifies the actual Netapp fqdn, then passes when the + datastore matches that fqdn. + + """ + hint_key = "same_host" + + def backend_passes(self, backend_state, filter_properties): + volumes = self._affinity_volumes(backend_state, filter_properties) + + if not volumes: + return True + + # Get the backend fqdn custom attribute for the volume + backend_datastore = volume_utils.extract_host(backend_state.host, + level='pool') + backend_fqdn = self._get_backend_fqdn(backend_datastore) + if not backend_fqdn: + # The datastore being filtered doesn't have a custom fqdn set + # Don't filter it out. + LOG.debug("Datastore {} has no fqdn", backend_datastore) + return True + + # If the result is a list of volumes, then we have to + # extract the datastore from the host entries from + # those volumes, then find the netapp associated with + # each of those and then only allow the same netapp to pass + for vol in volumes: + volume_datastore = volume_utils.extract_host(vol.host, + level='pool') + volume_fqdn = self._get_backend_fqdn(volume_datastore) + if volume_fqdn: + if volume_fqdn == backend_fqdn: + LOG.debug("Volume FQDN matches {}", + backend_fqdn, resource=vol) + return True + + return False diff --git a/cinder/scheduler/filters/sap_large_volume_filter.py b/cinder/scheduler/filters/sap_large_volume_filter.py new file mode 100644 index 00000000000..ae51a2ee852 --- /dev/null +++ b/cinder/scheduler/filters/sap_large_volume_filter.py @@ -0,0 +1,50 @@ +# Copyright (c) 2020 SAP SE +# +# All Rights Reserved. +# +# 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 oslo_config import cfg +from oslo_log import log as logging + +from cinder.scheduler import filters + + +LOG = logging.getLogger(__name__) +CONF = cfg.CONF + + +class SAPLargeVolumeFilter(filters.BaseBackendFilter): + """Filter out volumes from landing on vvol datastores for > 2TB""" + + def backend_passes(self, backend_state, filter_properties): + host = backend_state.host + + # if the request is against a non vvol host, we pass. + if 'vvol' not in host.lower(): + return True + + if filter_properties.get('new_size'): + requested_size = int(filter_properties.get('new_size')) + else: + requested_size = int(filter_properties.get('size')) + + # requested_size is 0 means that it's a manage request. + if requested_size == 0: + return True + + if requested_size > 2048: + LOG.info("Cannot allow volumes larger than 2048 GiB on vVol.") + return False + else: + return True diff --git a/cinder/scheduler/filters/sap_pool_down_filter.py b/cinder/scheduler/filters/sap_pool_down_filter.py new file mode 100644 index 00000000000..8d7fc951c9e --- /dev/null +++ b/cinder/scheduler/filters/sap_pool_down_filter.py @@ -0,0 +1,36 @@ +# Copyright (c) 2020 SAP SE +# +# All Rights Reserved. +# +# 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 oslo_log import log as logging + +from cinder.scheduler import filters + + +LOG = logging.getLogger(__name__) + + +class SAPPoolDownFilter(filters.BaseBackendFilter): + """Filter out pools that are not marked 'up'.""" + + def backend_passes(self, backend_state, filter_properties): + + if backend_state.pool_state == 'up': + return True + else: + LOG.debug("%(id)s pool state is not 'up'. state='%(state)s'", + {'id': backend_state.backend_id, + 'state': backend_state.pool_state}) + return False diff --git a/cinder/scheduler/filters/shard_filter.py b/cinder/scheduler/filters/shard_filter.py new file mode 100644 index 00000000000..87bc0810da7 --- /dev/null +++ b/cinder/scheduler/filters/shard_filter.py @@ -0,0 +1,297 @@ +# Copyright (c) 2020 SAP SE +# All Rights Reserved. +# +# 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 time + +from keystoneauth1 import exceptions as kse +from keystoneauth1 import loading as ks_loading +from oslo_config import cfg +from oslo_log import log as logging + +from cinder import db +from cinder.scheduler import filters +from cinder.service_auth import SERVICE_USER_GROUP +from cinder import utils as cinder_utils +from cinder.volume import volume_utils + + +LOG = logging.getLogger(__name__) +CONF = cfg.CONF + +_SERVICE_AUTH = None +KEYSTONE_GROUP = 'keystone' + + +# register keystone config options so we can create an adapter for it easily +ks_loading.register_session_conf_options(CONF, KEYSTONE_GROUP) +ks_loading.register_auth_conf_options(CONF, KEYSTONE_GROUP) +keystone_opts = ks_loading.get_adapter_conf_options() +cfg.set_defaults(keystone_opts, + valid_interfaces=['internal', 'public'], + service_type='identity') +CONF.register_opts(keystone_opts, group=KEYSTONE_GROUP) + +CSI_CLUSTER_METADATA_KEY = 'cinder.csi.openstack.org/cluster' + + +class ShardFilter(filters.BaseBackendFilter): + """Filters backends by shard of the project + + Every project has tags assigned, which define the vCenter the project is + in. This filter filters out any backend that's not configured for the shard + of a project. + + Alternatively the project may have the "sharding_enabled" tag set, which + enables the project for backends in all shards. + """ + + # project shards do not change within a request + run_filter_once_per_request = True + + _PROJECT_SHARD_CACHE = {} + _PROJECT_SHARD_CACHE_RETENTION_TIME = 10 * 60 + _SHARD_PREFIX = 'vc-' + _CAPABILITY_NAME = 'vcenter-shard' + _ALL_SHARDS = "sharding_enabled" + + def _get_keystone_adapter(self): + """Return a keystone adapter + + This needs [service_user] for the auth. + """ + global _SERVICE_AUTH + + if _SERVICE_AUTH is None: + _SERVICE_AUTH = ks_loading.load_auth_from_conf_options( + CONF, group=SERVICE_USER_GROUP) + if _SERVICE_AUTH is None: + # This indicates a misconfiguration so log a warning and + # return the user_auth. + LOG.error('Unable to load auth from %(group)s ' + 'configuration. Ensure "auth_type" is set.', + {'group': SERVICE_USER_GROUP}) + return + + ksa_session = ks_loading.load_session_from_conf_options( + CONF, + KEYSTONE_GROUP, + auth=_SERVICE_AUTH) + + return ks_loading.load_adapter_from_conf_options( + CONF, KEYSTONE_GROUP, session=ksa_session, auth=_SERVICE_AUTH, + min_version=(3, 0), max_version=(3, 'latest')) + + def _update_cache(self): + """Update the cache with infos from keystone + + Ask keystone for the list of projects to save the interesting tags + of each project in the cache. + """ + adap = self._get_keystone_adapter() + if not adap: + return + + # NOTE: the same code exists in nova + url = '/projects' + while url: + try: + resp = adap.get(url, raise_exc=False) + except kse.EndpointNotFound: + LOG.error( + "Keystone identity service version 3.0 was not found. " + "This might be because your endpoint points to the v2.0 " + "versioned endpoint which is not supported. Please fix " + "this.") + return + except kse.ClientException: + LOG.error("Unable to contact keystone to update project tags " + "cache") + return + + resp.raise_for_status() + + data = resp.json() + for project in data['projects']: + project_id = project['id'] + shards = [t for t in project['tags'] + if t.startswith(self._SHARD_PREFIX) + or t == self._ALL_SHARDS] + self._PROJECT_SHARD_CACHE[project_id] = shards + + url = data['links']['next'] + + self._PROJECT_SHARD_CACHE['last_modified'] = time.time() + + @cinder_utils.synchronized('update-shard-cache') + def _get_shards(self, project_id): + # expire the cache 10min after last write + last_modified = self._PROJECT_SHARD_CACHE.get('last_modified', 0) + time_diff = time.time() - last_modified + if time_diff > self._PROJECT_SHARD_CACHE_RETENTION_TIME: + self._PROJECT_SHARD_CACHE = {} + + if project_id not in self._PROJECT_SHARD_CACHE: + self._update_cache() + + return self._PROJECT_SHARD_CACHE.get(project_id) + + def _is_vmware(self, backend_state): + if backend_state.vendor_name != 'VMware': + return False + return True + + def filter_all(self, filter_obj_list, filter_properties): + backends = self._filter_by_k8s_cluster(filter_obj_list, + filter_properties) + + return [b for b in backends if + self._backend_passes(b, filter_properties)] + + def _filter_by_k8s_cluster(self, backends, filter_properties): + spec = filter_properties.get('request_spec', {}) + vol_props = spec.get('volume_properties', {}) + project_id = vol_props.get('project_id', None) + metadata = vol_props.get('metadata', {}) + + is_vmware = any(self._is_vmware(b) for b in backends) + if (not metadata or not project_id + or spec.get('snapshot_id') + or spec.get('operation') == 'find_backend_for_connector' + or not is_vmware): + return backends + + cluster_name = metadata.get(CSI_CLUSTER_METADATA_KEY) + if not cluster_name: + return backends + + availability_zone = filter_properties.get('availability_zone') + query_filters = None + if availability_zone: + query_filters = {'availability_zone': availability_zone} + + results = db.get_hosts_by_volume_metadata( + key=CSI_CLUSTER_METADATA_KEY, + value=cluster_name, + filters=query_filters) + + if not results: + return backends + + # Allowing new volumes to be created only in the dominant shard + if spec.get('operation') == 'create_volume': + results = results[:1] + + k8s_hosts = dict(results) + + def _is_k8s_host(b): + host = volume_utils.extract_host(b.host, 'host') + if host in k8s_hosts: + return True + else: + LOG.debug('%(backend)s not in the allowed ' + 'K8S hosts %(k8s_hosts)s.', + {'backend': b, + 'k8s_hosts': k8s_hosts}) + return False + + return [ + b for b in backends if + (not self._is_vmware(b) or _is_k8s_host(b)) + ] + + def _backend_passes(self, backend_state, filter_properties): + # We only need the shard filter for vmware based pools + if not self._is_vmware(backend_state): + LOG.info( + "Shard Filter ignoring backend %s as it's not " + "vmware based driver", backend_state.backend_id) + return True + + spec = filter_properties.get('request_spec', {}) + vol = spec.get('volume_properties', {}) + project_id = vol.get('project_id', None) + + volid = None + if spec: + volid = spec.get('volume_id') + + if spec.get('snapshot_id'): + # Snapshots always use the same host as the volume. + LOG.debug('Ignoring snapshot.') + return True + + if spec.get('operation') == 'find_backend_for_connector': + # We don't care about shards here, as we want to move a volume to + # an instance sitting in a specific vCenter. Shards are only used + # if we don't know where the volume is needed. + LOG.debug('Ignoring find_backend_for_connector scheduling.') + return True + + # allow an override of the automatic shard-detection like nova does for + # its compute-hosts + scheduler_hints = filter_properties.get('scheduler_hints') or {} + if self._CAPABILITY_NAME in scheduler_hints: + shards = set([scheduler_hints[self._CAPABILITY_NAME]]) + LOG.debug('Using overridden shards %(shards)s for scheduling.', + {'shards': shards}) + else: + if project_id is None: + LOG.debug('Could not determine the project for volume %(id)s.', + {'id': volid}) + return False + + shards = self._get_shards(project_id) + if shards is None: + LOG.error('Failure retrieving shards for project ' + '%(project_id)s.', + {'project_id': project_id}) + return False + + if not len(shards): + LOG.error('Project %(project_id)s is not assigned to any shard.', + {'project_id': project_id}) + return False + + # set extra_capabilities in the cinder-volume.conf, so we can filter on + # them here. + configured_shards_set = set() + cap = backend_state.capabilities.get(self._CAPABILITY_NAME) + if cap is not None: + configured_shards_set.update(cap.split(',')) + + if not configured_shards_set: + LOG.error('%(backend)s does not have any capability starting with ' + '%(shard_prefix)s.', + {'backend': backend_state, + 'shard_prefix': self._SHARD_PREFIX}) + return False + + if self._ALL_SHARDS in shards: + LOG.debug('project enabled for all shards %(project_shards)s.', + {'project_shards': shards}) + return True + elif configured_shards_set & set(shards): + LOG.debug('%(backend)s shard %(backend_shards)s found in project ' + 'shards %(project_shards)s.', + {'backend': backend_state, + 'backend_shards': configured_shards_set, + 'project_shards': shards}) + return True + else: + LOG.debug('%(backend)s shard %(backend_shards)s not found in ' + 'project shards %(project_shards)s.', + {'backend': backend_state, + 'backend_shards': configured_shards_set, + 'project_shards': shards}) + return False diff --git a/cinder/scheduler/host_manager.py b/cinder/scheduler/host_manager.py index 876e91dc43f..c1373209eb8 100644 --- a/cinder/scheduler/host_manager.py +++ b/cinder/scheduler/host_manager.py @@ -396,6 +396,7 @@ def update_from_volume_capability(self, capability, service=None): capability, CONF.max_over_subscription_ratio)) self.multiattach = capability.get('multiattach', False) + self.pool_state = capability.get('pool_state', 'up') self.filter_function = capability.get('filter_function', None) self.goodness_function = capability.get('goodness_function', 0) diff --git a/cinder/scheduler/manager.py b/cinder/scheduler/manager.py index d16385685f1..7e810dbdcaf 100644 --- a/cinder/scheduler/manager.py +++ b/cinder/scheduler/manager.py @@ -44,6 +44,7 @@ from cinder.message import message_field from cinder import objects from cinder.objects import fields +from cinder.objects import snapshot as snapshot_obj from cinder import quota from cinder import rpc from cinder.scheduler.flows import create_volume @@ -62,6 +63,14 @@ min=1, help='Maximum time in seconds to wait for the driver to ' 'report as ready'), + cfg.BoolOpt('sap_allow_independent_snapshots', + default=False, + help='Allow cinder to schedule snapshot creations on pools ' + 'other than the source volume pool.'), + cfg.BoolOpt('sap_allow_independent_clone', + default=False, + help='Allow cinder to schedule a clone volume on a pool ' + 'other than the source volume pool.'), ] CONF = cfg.CONF @@ -239,19 +248,33 @@ def create_snapshot(self, ctxt, volume, snapshot, backend, """ self._wait_for_scheduler() + if CONF.sap_allow_independent_snapshots: + # We allow snapshots to be created on a pool + # separate from the volume's pool. + backend = vol_utils.extract_host(volume['host']) + try: tgt_backend = self.driver.backend_passes_filters( ctxt, backend, request_spec, filter_properties) tgt_backend.consume_from_volume( {'size': request_spec['volume_properties']['size']}) + LOG.info("Snapshot picked backend_id is '%s'", + tgt_backend.backend_id) except exception.NoValidBackend as ex: self._set_snapshot_state_and_notify('create_snapshot', snapshot, fields.SnapshotStatus.ERROR, ctxt, ex, request_spec) else: - volume_rpcapi.VolumeAPI().create_snapshot(ctxt, volume, - snapshot) + if CONF.sap_allow_independent_snapshots: + # Set this in the metadata of the snap + key = snapshot_obj.SAP_HIDDEN_BACKEND_KEY + if snapshot.metadata: + snapshot.metadata[key] = tgt_backend.backend_id + else: + snapshot.metadata = {key: tgt_backend.backend_id} + snapshot.save() + volume_rpcapi.VolumeAPI().create_snapshot(ctxt, volume, snapshot) def _do_cleanup(self, ctxt, vo_resource): # We can only receive cleanup requests for volumes, but we check anyway @@ -367,6 +390,19 @@ def _retype_volume_set_error(self, context, ex, request_spec, reservations, old_reservations) + @append_operation_type() + def find_backend_for_connector(self, context, connector, request_spec, + volume_size, filter_properties=None): + self._wait_for_scheduler() + backend = self.driver.find_backend_for_connector(context, + connector, + request_spec, + filter_properties) + backend.consume_from_volume({'size': volume_size}) + return {'host': backend.host, + 'cluster_name': backend.cluster_name, + 'capabilities': backend.capabilities} + @append_operation_type() def manage_existing(self, context, volume, request_spec, filter_properties=None): @@ -470,15 +506,56 @@ def _extend_volume_set_error(self, context, ex, request_spec): {'size': new_size - volume.size}) volume_rpcapi.VolumeAPI().extend_volume(context, volume, new_size, reservations) - except exception.NoValidBackend as ex: - QUOTAS.rollback(context, reservations, - project_id=volume.project_id) - _extend_volume_set_error(self, context, ex, request_spec) - self.message_api.create( - context, - message_field.Action.EXTEND_VOLUME, - resource_uuid=volume.id, - exception=ex) + except exception.NoValidBackend: + try: + self._extend_migrate(context, volume, new_size, request_spec, + filter_properties, reservations) + except exception.NoValidBackend as ex: + QUOTAS.rollback(context, reservations, + project_id=volume.project_id) + _extend_volume_set_error(self, context, ex, request_spec) + self.message_api.create( + context, + message_field.Action.EXTEND_VOLUME, + resource_uuid=volume.id, + exception=ex) + + def _extend_migrate(self, context, volume, new_size, request_spec, + filter_properties, reservations): + + if volume.consistencygroup_id or volume.group_id: + raise exception.NoValidBackend( + reason='The volume is in a group and cannot be migrated.') + + scheduler_hints = \ + vol_utils.get_scheduler_hints_from_volume(volume) + filter_properties.update(scheduler_hints) + filter_properties.pop('new_size') + + if not request_spec: + request_spec = {'volume_properties': {'size': new_size}} + else: + request_spec['volume_properties']['size'] = new_size + + if volume['availability_zone']: + request_spec['resource_properties'] = { + 'availability_zone': volume['availability_zone']} + + # SAP + # We have to force the destination host to be on + # the same backend, or it might get migrated + # to another vcenter. + backend = vol_utils.extract_host(volume['host']) + + backend_state = self.driver.backend_passes_filters( + context, backend, request_spec, filter_properties) + + backend_state.consume_from_volume(volume) + + volume_rpcapi.VolumeAPI().migrate_volume( + context, volume, backend_state, + force_host_copy=False, wait_for_completion=False, + extend_spec={'new_size': new_size, 'reservations': reservations}) def _set_volume_state_and_notify(self, method, updates, context, ex, request_spec, msg=None): diff --git a/cinder/scheduler/rpcapi.py b/cinder/scheduler/rpcapi.py index bc421b793f3..12f7b6b5ded 100644 --- a/cinder/scheduler/rpcapi.py +++ b/cinder/scheduler/rpcapi.py @@ -267,3 +267,11 @@ def create_backup(self, ctxt, backup): cctxt = self._get_cctxt() msg_args = {'backup': backup} return cctxt.cast(ctxt, 'create_backup', **msg_args) + + def find_backend_for_connector(self, context, connector, request_spec, + volume_size, filter_properties=None): + cctxt = self._get_cctxt() + return cctxt.call(context, 'find_backend_for_connector', + connector=connector, request_spec=request_spec, + volume_size=volume_size, + filter_properties=filter_properties) diff --git a/cinder/tests/unit/attachments/test_attachments_manager.py b/cinder/tests/unit/attachments/test_attachments_manager.py index f540123b1a2..f545e029c02 100644 --- a/cinder/tests/unit/attachments/test_attachments_manager.py +++ b/cinder/tests/unit/attachments/test_attachments_manager.py @@ -243,12 +243,13 @@ def test_connection_terminate_no_connector_force_false(self): # does not have a connector will not call the driver and return None # if the force flag is False. attachment = mock.MagicMock(connector={}) + volume = mock.MagicMock() with mock.patch.object(self.manager.driver, '_initialized', create=True, new=True): with mock.patch.object(self.manager.driver, 'terminate_connection') as term_conn: has_shared_connection = self.manager._connection_terminate( - self.context, mock.sentinel.volume, attachment) + self.context, volume, attachment) self.assertIsNone(has_shared_connection) term_conn.assert_not_called() diff --git a/cinder/tests/unit/backup/drivers/test_backup_handle.py b/cinder/tests/unit/backup/drivers/test_backup_handle.py new file mode 100644 index 00000000000..dea2088cbe4 --- /dev/null +++ b/cinder/tests/unit/backup/drivers/test_backup_handle.py @@ -0,0 +1,199 @@ +# Copyright (C) 2020 SAP SE +# All Rights Reserved. +# +# 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 unittest import mock + +from cinder.backup import chunkeddriver +from cinder.tests.unit import test + + +class BackupRestoreHandleV1TestCase(test.TestCase): + + BACKUP_RESTORE_HANDLE = chunkeddriver.BackupRestoreHandle + + def setUp(self): + super(BackupRestoreHandleV1TestCase, self).setUp() + self._driver = mock.Mock() + self._volume_file = mock.Mock() + self._volume_id = 'volume-01' + self._obj = { + 'offset': 100, + 'length': 50, + 'container': 'obj_container', + 'name': 'obj_name', + 'backup_id': 'backup-1', + 'extra_metadata': {'foo': 'bar'}, + 'compression': None + } + self._segment = chunkeddriver.Segment(self._obj) + + def test_add_object(self): + obj1 = {'name': 'obj1', 'offset': 0, 'length': 100} + obj2 = {'name': 'obj2', 'offset': 100, 'length': 100} + # incremental + obj3 = {'name': 'obj3', 'offset': 50, 'length': 100} + obj4 = {'name': 'obj4', 'offset': 60, 'length': 50} + handle = chunkeddriver.BackupRestoreHandleV1(self._driver, + self._volume_id, + self._volume_file) + handle.add_object(obj1) + handle.add_object(obj2) + handle.add_object(obj3) + handle.add_object(obj4) + + ranges = handle._segments + + self.assertEqual(0, ranges[0].offset) + self.assertEqual(50, ranges[0].end) + + self.assertEqual(50, ranges[1].offset) + self.assertEqual(60, ranges[1].end) + + self.assertEqual(60, ranges[2].offset) + self.assertEqual(100, ranges[2].end) + + self.assertEqual(100, ranges[3].offset) + self.assertEqual(110, ranges[3].end) + + self.assertEqual(110, ranges[4].offset) + self.assertEqual(150, ranges[4].end) + + self.assertEqual(150, ranges[5].offset) + self.assertEqual(200, ranges[5].end) + + @mock.patch.object(BACKUP_RESTORE_HANDLE, '_get_reader') + @mock.patch.object(BACKUP_RESTORE_HANDLE, '_clear_reader') + def test_read_segment(self, clear_reader, get_reader): + buff_reader_mock = mock.Mock() + buff_reader_mock.read.return_value = b"foo" + get_reader.return_value = buff_reader_mock + + handle = chunkeddriver.BackupRestoreHandleV1(self._driver, + self._volume_id, + self._volume_file) + data = handle._read_segment(self._segment) + + get_reader.assert_called_once_with(self._segment) + buff_reader_mock.seek.assert_called_once_with( + self._segment.offset - self._segment.obj['offset']) + clear_reader.assert_called_once_with(self._segment) + self.assertEqual(data, b"foo") + + @mock.patch.object(BACKUP_RESTORE_HANDLE, '_get_new_reader') + def test_get_reader(self, get_new_reader): + new_reader = mock.Mock() + get_new_reader.return_value = new_reader + handle = chunkeddriver.BackupRestoreHandleV1(self._driver, + self._volume_id, + self._volume_file) + handle._get_reader(self._segment) + get_new_reader.assert_called_once_with(self._segment) + self.assertEqual(handle._object_readers, { + self._segment.obj['name']: new_reader + }) + + @mock.patch.object(BACKUP_RESTORE_HANDLE, '_get_raw_bytes') + def test_get_new_reader(self, get_raw_bytes): + raw_bytes = b'data' + get_raw_bytes.return_value = raw_bytes + obj_reader = mock.Mock() + get_obj_reader = mock.Mock() + get_obj_reader.__enter__ = mock.Mock(return_value=obj_reader) + get_obj_reader.__exit__ = mock.Mock(return_value=False) + self._driver._get_object_reader.return_value = get_obj_reader + handle = chunkeddriver.BackupRestoreHandleV1(self._driver, + self._volume_id, + self._volume_file) + bytes_io = handle._get_new_reader(self._segment) + self._driver._get_object_reader.assert_called_once_with( + self._segment.obj['container'], + self._segment.obj['name'], + extra_metadata=self._segment.obj['extra_metadata']) + get_raw_bytes.assert_called_once_with(obj_reader, self._segment.obj) + self.assertEqual(bytes_io.getvalue(), raw_bytes) + + def test_get_raw_bytes(self, decompress=False): + compressor = None + obj = self._obj.copy() + if decompress: + compressor = mock.Mock() + obj['compression'] = 'zlib' + reader = mock.Mock() + reader_ret = mock.Mock() + reader.read.return_value = reader_ret + self._driver._get_compressor.return_value = compressor + + handle = chunkeddriver.BackupRestoreHandleV1(self._driver, + self._volume_id, + self._volume_file) + handle._get_raw_bytes(reader, obj) + + self._driver._get_compressor.\ + assert_called_once_with(obj['compression']) + reader.read.assert_called_once_with() + + if decompress: + compressor.decompress.assert_called_once_with(reader_ret) + + def test_get_raw_bytes_decompressed(self): + self.test_get_raw_bytes(decompress=True) + + def test_clear_reader(self): + obj_reader = mock.Mock() + obj = self._obj.copy() + obj['name'] = 'obj_name_2' + obj_readers = {self._obj['name']: obj_reader} + obj_readers_mock = mock.MagicMock() + obj_readers_mock.__getitem__.side_effect = obj_readers.__getitem__ + + handle = chunkeddriver.BackupRestoreHandleV1(self._driver, + self._volume_id, + self._volume_file) + handle._object_readers = obj_readers_mock + handle._segments = [self._segment, + chunkeddriver.Segment(obj), + chunkeddriver.Segment(self._obj)] + + handle._idx = 0 + handle._clear_reader(self._segment) + obj_readers_mock.__getitem__.assert_not_called() + obj_reader.close.assert_not_called() + + handle._idx = 1 + handle._clear_reader(self._segment) + obj_readers_mock.__getitem__.assert_not_called() + obj_reader.close.assert_not_called() + + handle._idx = 2 + handle._clear_reader(self._segment) + obj_readers_mock.__getitem__.assert_called_once_with(self._obj['name']) + obj_reader.close.assert_called_once_with() + + @mock.patch.object(BACKUP_RESTORE_HANDLE, '_read_segment') + def test_finish_restore(self, read_segment): + segment_bytes = b'foo' + read_segment.return_value = segment_bytes + file_handle = mock.Mock() + file_handle.fileno.side_effect = IOError + handle = chunkeddriver.BackupRestoreHandleV1(self._driver, + self._volume_id, + file_handle) + handle._segments = [self._segment] + + handle.finish_restore() + + read_segment.assert_called_once_with(self._segment) + file_handle.write.assert_called_once_with(segment_bytes) + file_handle.fileno.assert_called_once_with() + self.assertEqual(handle._idx, 0) diff --git a/cinder/tests/unit/backup/test_backup.py b/cinder/tests/unit/backup/test_backup.py index 970cbc7654e..82d7f1bc9b0 100644 --- a/cinder/tests/unit/backup/test_backup.py +++ b/cinder/tests/unit/backup/test_backup.py @@ -626,7 +626,7 @@ def test_create_backup_with_error(self): vol_id = self._create_volume_db_entry(size=1) backup = self._create_backup_db_entry(volume_id=vol_id) - mock_run_backup = self.mock_object(self.backup_mgr, '_run_backup') + mock_run_backup = self.mock_object(self.backup_mgr, '_start_backup') mock_run_backup.side_effect = FakeBackupException(str(uuid.uuid4())) self.assertRaises(FakeBackupException, self.backup_mgr.create_backup, @@ -639,22 +639,24 @@ def test_create_backup_with_error(self): self.assertEqual(fields.BackupStatus.ERROR, backup['status']) self.assertTrue(mock_run_backup.called) - @mock.patch('cinder.backup.manager.BackupManager._run_backup') - def test_create_backup_aborted(self, run_backup_mock): + @mock.patch('cinder.backup.manager.BackupManager._start_backup') + def test_create_backup_aborted(self, start_backup_mock): """Test error handling when abort occurs during backup creation.""" - def my_run_backup(*args, **kwargs): + def my_start_backup(*args, **kwargs): backup.destroy() with backup.as_read_deleted(): original_refresh() - run_backup_mock.side_effect = my_run_backup + start_backup_mock.side_effect = my_start_backup vol_id = self._create_volume_db_entry(size=1) backup = self._create_backup_db_entry(volume_id=vol_id) original_refresh = backup.refresh self.backup_mgr.create_backup(self.ctxt, backup) + vol = objects.Volume.get_by_id(self.ctxt, vol_id) + self.backup_mgr._finish_backup(self.ctxt, backup, vol, {}) - self.assertTrue(run_backup_mock.called) + self.assertTrue(start_backup_mock.called) vol = objects.Volume.get_by_id(self.ctxt, vol_id) self.assertEqual('available', vol.status) @@ -664,9 +666,9 @@ def my_run_backup(*args, **kwargs): backup.refresh() self.assertEqual(fields.BackupStatus.DELETED, backup.status) - @mock.patch('cinder.backup.manager.BackupManager._run_backup', + @mock.patch('cinder.backup.manager.BackupManager._start_backup', side_effect=FakeBackupException(str(uuid.uuid4()))) - def test_create_backup_with_snapshot_error(self, mock_run_backup): + def test_create_backup_with_snapshot_error(self, mock_start_backup): """Test error handling when error occurs during backup creation.""" vol_id = self._create_volume_db_entry(size=1) snapshot = self._create_snapshot_db_entry(status='backing-up', @@ -683,7 +685,7 @@ def test_create_backup_with_snapshot_error(self, mock_run_backup): backup.refresh() self.assertEqual(fields.BackupStatus.ERROR, backup.status) - self.assertTrue(mock_run_backup.called) + self.assertTrue(mock_start_backup.called) @mock.patch('cinder.volume.volume_utils.brick_get_connector_properties') @mock.patch('cinder.volume.rpcapi.VolumeAPI.get_backup_device') @@ -700,7 +702,7 @@ def test_create_backup(self, mock_isdir, mock_open, mock_temporary_chown, vol = objects.Volume.get_by_id(self.ctxt, vol_id) backup_device_dict = {'backup_device': vol, 'secure_enabled': False, 'is_snapshot': False, } - mock_get_backup_device.return_value = ( + mock_backup_device = ( objects.BackupDeviceInfo.from_primitive(backup_device_dict, self.ctxt, ['admin_metadata', @@ -715,6 +717,7 @@ def test_create_backup(self, mock_isdir, mock_open, mock_temporary_chown, mock_get_conn.return_value = properties self.backup_mgr.create_backup(self.ctxt, backup) + self.backup_mgr.continue_backup(self.ctxt, backup, mock_backup_device) mock_temporary_chown.assert_called_once_with('/dev/null') mock_attach_device.assert_called_once_with(self.ctxt, vol, @@ -764,7 +767,8 @@ def test_create_backup_set_parent_id_to_none(self, mock_isdir, mock_open, mock_open.return_value = open('/dev/null', 'rb') mock_brick.return_value = properties - self.backup_mgr.create_backup(self.ctxt, backup) + self.backup_mgr.continue_backup(self.ctxt, backup, + mock_backup_device) backup = db.backup_get(self.ctxt, backup.id) self.assertEqual(fields.BackupStatus.AVAILABLE, backup.status) @@ -799,7 +803,8 @@ def test_create_backup_set_parent_id(self, mock_isdir, mock_open, mock_open.return_value = open('/dev/null', 'rb') mock_brick.return_value = properties - self.backup_mgr.create_backup(self.ctxt, backup) + self.backup_mgr.continue_backup(self.ctxt, backup, + mock_backup_device) backup = db.backup_get(self.ctxt, backup.id) self.assertEqual(fields.BackupStatus.AVAILABLE, backup.status) @@ -834,8 +839,8 @@ def test_create_backup_fail_with_excep(self, mock_isdir, mock_open, mock_brick.return_value = properties self.assertRaises(FakeBackupException, - self.backup_mgr.create_backup, - self.ctxt, backup) + self.backup_mgr.continue_backup, + self.ctxt, backup, mock_backup_device) vol = db.volume_get(self.ctxt, vol_id) self.assertEqual('available', vol.status) @@ -843,6 +848,7 @@ def test_create_backup_fail_with_excep(self, mock_isdir, mock_open, backup = db.backup_get(self.ctxt, backup.id) self.assertEqual(fields.BackupStatus.ERROR, backup.status) + @mock.patch('cinder.backup.manager.BackupManager._finish_backup') @mock.patch('cinder.volume.volume_utils.brick_get_connector_properties') @mock.patch('cinder.volume.rpcapi.VolumeAPI.get_backup_device') @mock.patch('cinder.utils.temporary_chown') @@ -852,7 +858,8 @@ def test_run_backup_with_dir_device_path(self, mock_isdir, mock_open, mock_chown, mock_backup_device, - mock_brick): + mock_brick, + mock_finish): backup_service = mock.Mock() backup_service.backup = mock.Mock( return_value=mock.sentinel.backup_update) @@ -868,22 +875,24 @@ def test_run_backup_with_dir_device_path(self, mock_isdir, self.backup_mgr._attach_device = mock.Mock( return_value=attach_info) self.backup_mgr._detach_device = mock.Mock() - output = self.backup_mgr._run_backup(self.ctxt, backup, volume) + self.backup_mgr.continue_backup(self.ctxt, backup, + mock_backup_device) mock_chown.assert_not_called() mock_open.assert_not_called() backup_service.backup.assert_called_once_with( backup, device_path) - self.assertEqual(mock.sentinel.backup_update, output) + mock_finish.called_once_with(self.ctxt, backup, volume, + mock.sentinel.backup_update) - @mock.patch('cinder.backup.manager.BackupManager._run_backup') + @mock.patch('cinder.backup.manager.BackupManager._start_backup') @ddt.data((fields.SnapshotStatus.BACKING_UP, 'available'), (fields.SnapshotStatus.BACKING_UP, 'in-use'), (fields.SnapshotStatus.AVAILABLE, 'available'), (fields.SnapshotStatus.AVAILABLE, 'in-use')) @ddt.unpack def test_create_backup_with_snapshot(self, snapshot_status, volume_status, - mock_run_backup): + mock_start_backup): vol_id = self._create_volume_db_entry(status=volume_status) snapshot = self._create_snapshot_db_entry(volume_id=vol_id, status=snapshot_status) @@ -892,6 +901,9 @@ def test_create_backup_with_snapshot(self, snapshot_status, volume_status, if snapshot_status == fields.SnapshotStatus.BACKING_UP: self.backup_mgr.create_backup(self.ctxt, backup) + vol = objects.Volume.get_by_id(self.ctxt, vol_id) + self.backup_mgr._finish_backup(self.ctxt, backup, vol, {}) + vol = objects.Volume.get_by_id(self.ctxt, vol_id) snapshot = objects.Snapshot.get_by_id(self.ctxt, snapshot.id) @@ -920,7 +932,7 @@ def test_create_backup_with_temp_snapshot(self, mock_isdir, snap = self._create_snapshot_db_entry(volume_id=vol_id) vol = objects.Volume.get_by_id(self.ctxt, vol_id) - mock_get_backup_device.return_value = ( + mock_backup_device = ( objects.BackupDeviceInfo.from_primitive({ 'backup_device': snap, 'secure_enabled': False, 'is_snapshot': True, }, @@ -945,6 +957,7 @@ def test_create_backup_with_temp_snapshot(self, mock_isdir, mock_open.return_value = open('/dev/null', 'rb') self.backup_mgr.create_backup(self.ctxt, backup) + self.backup_mgr.continue_backup(self.ctxt, backup, mock_backup_device) mock_temporary_chown.assert_called_once_with('/dev/null') mock_initialize_connection_snapshot.assert_called_once_with( self.ctxt, snap, properties) @@ -1020,9 +1033,9 @@ def test_create_backup_with_notify(self, notify): vol_id = self._create_volume_db_entry(size=vol_size) backup = self._create_backup_db_entry(volume_id=vol_id) - self.mock_object(self.backup_mgr, '_run_backup') + self.mock_object(self.backup_mgr, '_start_backup') self.backup_mgr.create_backup(self.ctxt, backup) - self.assertEqual(2, notify.call_count) + self.assertEqual(1, notify.call_count) @mock.patch('cinder.volume.rpcapi.VolumeAPI.get_backup_device') @mock.patch('cinder.volume.volume_utils.clone_encryption_key') @@ -1884,7 +1897,7 @@ def test_backup_max_operations_restore(self, mock_restore): self.assertEqual(1, mock_restore.call_count) self.assertEqual(1, mock_sem.__exit__.call_count) - @mock.patch('cinder.backup.manager.BackupManager._run_backup') + @mock.patch('cinder.backup.manager.BackupManager._start_backup') def test_backup_max_operations_backup(self, mock_backup): mock_sem = self.mock_object(self.backup_mgr, '_semaphore') vol_id = self._create_volume_db_entry( diff --git a/cinder/tests/unit/backup/test_chunkeddriver.py b/cinder/tests/unit/backup/test_chunkeddriver.py index 19583df57ea..2839e5e2441 100644 --- a/cinder/tests/unit/backup/test_chunkeddriver.py +++ b/cinder/tests/unit/backup/test_chunkeddriver.py @@ -455,7 +455,8 @@ def test_backup_invalid_size(self): self.backup, mock.Mock()) - def test_restore(self): + @mock.patch('cinder.backup.chunkeddriver.BackupRestoreHandleV1.add_backup') + def test_restore(self, mock_add_backup): volume_file = mock.Mock() restore_test = mock.Mock() self.driver._restore_v1 = restore_test @@ -468,7 +469,7 @@ def test_restore(self): self.driver.restore(backup, self.volume, volume_file) self.assertEqual(2, mock_put.call_count) - restore_test.assert_called() + mock_add_backup.assert_called() def test_delete_backup(self): with mock.patch.object(self.driver, 'delete_object') as mock_delete: diff --git a/cinder/tests/unit/scheduler/test_filter_scheduler.py b/cinder/tests/unit/scheduler/test_filter_scheduler.py index 142872ba787..f1e2432a3d6 100644 --- a/cinder/tests/unit/scheduler/test_filter_scheduler.py +++ b/cinder/tests/unit/scheduler/test_filter_scheduler.py @@ -558,3 +558,128 @@ def test_retype_policy_demand_migrate_fail(self, _mock_service_get_topic): self.assertRaises(exception.NoValidBackend, sched.find_retype_backend, ctx, request_spec, filter_properties={}, migration_policy='on-demand') + + @mock.patch('cinder.db.service_get_all') + def test_find_backend_for_connector_with_valid_connection_capabilities( + self, _mock_service_get_all): + sched = fakes.FakeFilterScheduler() + host_mgr = fakes.FakeHostManager() + host_mgr.service_states['host3']['connection_capabilities'] = [ + 'host3', 'host-3', 'common'] + host_mgr.service_states['host1']['connection_capabilities'] = [ + 'host1', 'host-1', 'common'] + sched.host_manager = host_mgr + ctx = context.RequestContext('user', 'project', is_admin=True) + + fakes.mock_host_manager_db_calls(_mock_service_get_all) + request_spec = {'volume_id': fake.VOLUME_ID, + 'volume_type': {'name': 'LVM_iSCSI'}, + 'volume_properties': {'project_id': 1, + 'size': 10}} + request_spec = objects.RequestSpec.from_primitives(request_spec) + + # host1 is the top weighed backend + connector = {'connection_capabilities': []} + backend = sched.find_backend_for_connector(ctx, connector, + request_spec) + self.assertEqual('host1', volume_utils.extract_host(backend.host)) + + connector = {'connection_capabilities': ['common']} + backend = sched.find_backend_for_connector(ctx, connector, + request_spec) + self.assertEqual('host1', volume_utils.extract_host(backend.host)) + + connector = {'connection_capabilities': ['host-1']} + backend = sched.find_backend_for_connector(ctx, connector, + request_spec) + self.assertEqual('host1', volume_utils.extract_host(backend.host)) + + connector = {'connection_capabilities': ['host-1', 'host1']} + backend = sched.find_backend_for_connector(ctx, connector, + request_spec) + self.assertEqual('host1', volume_utils.extract_host(backend.host)) + + # host3 has a lower weight but it matches the + # connection_capabilities exposed by the connector + connector = {'connection_capabilities': ['host-3']} + backend = sched.find_backend_for_connector(ctx, connector, + request_spec) + self.assertEqual('host3', volume_utils.extract_host(backend.host)) + + connector = {'connection_capabilities': ['host3', 'host-3']} + backend = sched.find_backend_for_connector(ctx, connector, + request_spec) + self.assertEqual('host3', volume_utils.extract_host(backend.host)) + + @mock.patch('cinder.db.service_get_all') + def test_find_backend_for_connector_with_invalid_connection_capabilities( + self, _mock_service_get_all): + sched = fakes.FakeFilterScheduler() + host_mgr = fakes.FakeHostManager() + host_mgr.service_states['host1']['connection_capabilities'] = [ + 'host-1'] + host_mgr.service_states['host3']['connection_capabilities'] = [ + 'host-3'] + sched.host_manager = host_mgr + + ctx = context.RequestContext('user', 'project', is_admin=True) + + fakes.mock_host_manager_db_calls(_mock_service_get_all) + request_spec = {'volume_id': fake.VOLUME_ID, + 'volume_type': {'name': 'LVM_iSCSI'}, + 'volume_properties': {'project_id': 1, + 'size': 10}} + request_spec = objects.RequestSpec.from_primitives(request_spec) + + connector = {'connection_capabilities': ['unknown_capability']} + self.assertRaises(exception.NoValidBackend, + sched.find_backend_for_connector, + ctx, connector, request_spec) + + connector = {'connection_capabilities': ['host-1', + 'unknown_capability']} + self.assertRaises(exception.NoValidBackend, + sched.find_backend_for_connector, + ctx, connector, request_spec) + + connector = {'connection_capabilities': ['host-3', + 'host-1']} + self.assertRaises(exception.NoValidBackend, + sched.find_backend_for_connector, + ctx, connector, request_spec) + + connector = {} + self.assertRaises(exception.InvalidConnectionCapabilities, + sched.find_backend_for_connector, + ctx, connector, request_spec) + + @mock.patch('cinder.db.service_get_all') + def test_find_backend_for_connector_with_bigger_volume( + self, _mock_service_get_all): + sched = fakes.FakeFilterScheduler() + host_mgr = fakes.FakeHostManager() + host_mgr.service_states['host3']['connection_capabilities'] = [ + 'host-3'] + host_mgr.service_states['host1']['connection_capabilities'] = [ + 'host-1'] + sched.host_manager = host_mgr + + ctx = context.RequestContext('user', 'project', is_admin=True) + + fakes.mock_host_manager_db_calls(_mock_service_get_all) + request_spec = {'volume_id': fake.VOLUME_ID, + 'volume_type': {'name': 'LVM_iSCSI'}, + 'volume_properties': {'project_id': 1, + 'size': 300}} + + # host3 won't fit 300 gb + connector = {'connection_capabilities': ['host-3']} + self.assertRaises(exception.NoValidBackend, + sched.find_backend_for_connector, + ctx, connector, request_spec) + + # host1 will fit it + connector = {'connection_capabilities': []} + backend = sched.find_backend_for_connector(ctx, connector, + request_spec) + self.assertEqual('host1', volume_utils.extract_host(backend.host)) diff --git a/cinder/tests/unit/scheduler/test_scheduler.py b/cinder/tests/unit/scheduler/test_scheduler.py index 4036e7d59ab..14eb279d8d4 100644 --- a/cinder/tests/unit/scheduler/test_scheduler.py +++ b/cinder/tests/unit/scheduler/test_scheduler.py @@ -212,9 +212,11 @@ def test_extend_volume_no_valid_host(self, status, mock_create, mock_backend_passes): volume = fake_volume.fake_volume_obj(self.context, **{'size': 1, + 'host': 'fake_host', 'previous_status': status}) no_valid_backend = exception.NoValidBackend(reason='') - mock_backend_passes.side_effect = [no_valid_backend] + mock_backend_passes.side_effect = [no_valid_backend, + no_valid_backend] with mock.patch.object(self.manager, '_set_volume_state_and_notify') as mock_notify: @@ -234,6 +236,28 @@ def test_extend_volume_no_valid_host(self, status, mock_create, resource_uuid=volume.id, exception=no_valid_backend) + @ddt.data('available', 'in-use') + @mock.patch('cinder.volume.rpcapi.VolumeAPI.migrate_volume') + @mock.patch('cinder.scheduler.driver.Scheduler.backend_passes_filters') + def test_extend_volume_migrate_in_group(self, + status, + mock_backend_passes, + mock_migrate): + volume = fake_volume.fake_volume_obj(self.context, + **{'size': 1, + 'group_id': fake.GROUP_ID, + 'previous_status': status}) + no_valid_backend = exception.NoValidBackend(reason='') + mock_backend_passes.side_effect = [no_valid_backend] + + with mock.patch.object(self.manager, + '_set_volume_state_and_notify') as mock_notify: + self.manager.extend_volume(self.context, volume, 2, + 'fake_reservation') + posargs = mock_notify.call_args[0] + self.assertIsInstance(posargs[3], exception.NoValidBackend) + mock_migrate.assert_not_called() + @mock.patch('cinder.quota.QuotaEngine.expire') def test_clean_expired_reservation(self, mock_clean): @@ -628,6 +652,25 @@ def test_create_backup_no_service(self, mock_volume_update, mock_error.assert_called_once_with( backup, 'Service not found for creating backup.') + @mock.patch('cinder.scheduler.driver.Scheduler.find_backend_for_connector') + def test_find_backend_for_connector(self, _mock_find_backend_for_conector): + connector = mock.Mock() + request_spec = mock.Mock() + volume_size = mock.Mock() + backend_ret = mock.Mock(host='fake-host', + cluster_name='fake-cluster', capabilities=[]) + _mock_find_backend_for_conector.return_value = backend_ret + ret = self.manager.find_backend_for_connector(self.context, + connector, request_spec, + volume_size) + _mock_find_backend_for_conector.assert_called_once_with( + self.context, connector, request_spec, None) + self.assertEqual(ret, { + 'host': backend_ret.host, + 'cluster_name': backend_ret.cluster_name, + 'capabilities': backend_ret.capabilities + }) + class SchedulerTestCase(test.TestCase): """Test case for base scheduler driver class.""" diff --git a/cinder/tests/unit/scheduler/test_shard_filter.py b/cinder/tests/unit/scheduler/test_shard_filter.py new file mode 100644 index 00000000000..f2a9ed584ea --- /dev/null +++ b/cinder/tests/unit/scheduler/test_shard_filter.py @@ -0,0 +1,275 @@ +# Copyright 2020 SAP SE # All Rights Reserved. +# +# 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 time +from unittest import mock + +from cinder import context +from cinder.tests.unit import fake_constants +from cinder.tests.unit.scheduler import fakes +from cinder.tests.unit.scheduler.test_host_filters \ + import BackendFiltersTestCase + +VMWARE_VENDOR = 'VMware' + + +class ShardFilterTestCase(BackendFiltersTestCase): + + def setUp(self): + super(ShardFilterTestCase, self).setUp() + self.filt_cls = self.class_map['ShardFilter']() + self.filt_cls._PROJECT_SHARD_CACHE = { + 'foo': ['vc-a-0', 'vc-b-0'], + 'last_modified': time.time() + } + self.props = { + 'request_spec': { + 'volume_properties': { + 'project_id': 'foo' + } + } + } + self.context = context.RequestContext(fake_constants.USER_ID, + fake_constants.PROJECT_ID) + + @mock.patch('cinder.scheduler.filters.shard_filter.' + 'ShardFilter._update_cache') + def test_get_shards_cache_timeout(self, mock_update_cache): + def set_cache(): + self.filt_cls._PROJECT_SHARD_CACHE = { + 'foo': ['vc-a-1'] + } + mock_update_cache.side_effect = set_cache + + project_id = 'foo' + mod = time.time() - self.filt_cls._PROJECT_SHARD_CACHE_RETENTION_TIME + + self.assertEqual(self.filt_cls._get_shards(project_id), + ['vc-a-0', 'vc-b-0']) + + self.filt_cls._PROJECT_SHARD_CACHE['last_modified'] = mod + self.assertEqual(self.filt_cls._get_shards(project_id), ['vc-a-1']) + + @mock.patch('cinder.scheduler.filters.shard_filter.' + 'ShardFilter._update_cache') + def test_get_shards_project_not_included(self, mock_update_cache): + def set_cache(): + self.filt_cls._PROJECT_SHARD_CACHE = { + 'bar': ['vc-a-1', 'vc-b-0'] + } + mock_update_cache.side_effect = set_cache + + self.assertEqual(self.filt_cls._get_shards('bar'), + ['vc-a-1', 'vc-b-0']) + mock_update_cache.assert_called_once() + + @mock.patch('cinder.scheduler.filters.shard_filter.' + 'ShardFilter._update_cache') + def test_shard_project_not_found(self, mock_update_cache): + caps = {'vcenter-shard': 'vc-a-1'} + self.props['request_spec']['volume_properties']['project_id'] = 'bar' + host = fakes.FakeBackendState('host1', + {'capabilities': caps, + 'vendor_name': VMWARE_VENDOR}) + self.backend_no_pass(host, self.props) + + def test_snapshot(self): + snap_props = { + 'request_spec': { + 'snapshot_id': 'asdf', + 'volume_properties': {'size': 7} + } + } + caps = {'vcenter-shard': 'vc-a-1'} + host = fakes.FakeBackendState('host1', + {'capabilities': caps, + 'vendor_name': VMWARE_VENDOR}) + self.backend_passes(host, snap_props) + + def test_snapshot_None(self): + snap_props = { + 'request_spec': { + 'snapshot_id': None, + 'volume_properties': {'size': 7} + } + } + caps = {'vcenter-shard': 'vc-a-1'} + host = fakes.FakeBackendState('host1', + {'capabilities': caps, + 'vendor_name': VMWARE_VENDOR}) + self.backend_no_pass(host, snap_props) + + def test_shard_project_no_shards(self): + caps = {'vcenter-shard': 'vc-a-1'} + self.filt_cls._PROJECT_SHARD_CACHE['foo'] = [] + host = fakes.FakeBackendState('host1', + {'capabilities': caps, + 'vendor_name': VMWARE_VENDOR}) + self.backend_no_pass(host, self.props) + + def test_backend_without_shard(self): + host = fakes.FakeBackendState('host1', {'vendor_name': VMWARE_VENDOR}) + self.backend_no_pass(host, self.props) + + def test_backend_shards_dont_match(self): + caps = {'vcenter-shard': 'vc-a-1'} + host = fakes.FakeBackendState('host1', + {'capabilities': caps, + 'vendor_name': VMWARE_VENDOR}) + self.backend_no_pass(host, self.props) + + def test_backend_shards_match(self): + caps = {'vcenter-shard': 'vc-b-0'} + host = fakes.FakeBackendState('host1', + {'capabilities': caps, + 'vendor_name': VMWARE_VENDOR}) + self.backend_passes(host, self.props) + + def test_shard_override_matches(self): + caps = {'vcenter-shard': 'vc-a-1'} + host = fakes.FakeBackendState('host1', + {'capabilities': caps, + 'vendor_name': VMWARE_VENDOR}) + self.props['scheduler_hints'] = {'vcenter-shard': 'vc-a-1'} + self.backend_passes(host, self.props) + + def test_shard_override_no_match(self): + caps = {'vcenter-shard': 'vc-a-0'} + host = fakes.FakeBackendState('host1', + {'capabilities': caps, + 'vendor_name': VMWARE_VENDOR}) + self.props['scheduler_hints'] = {'vcenter-shard': 'vc-a-1'} + self.backend_no_pass(host, self.props) + + def test_shard_override_no_data(self): + caps = {'vcenter-shard': 'vc-a-0'} + host = fakes.FakeBackendState('host1', + {'capabilities': caps, + 'vendor_name': VMWARE_VENDOR}) + self.props['scheduler_hints'] = {'vcenter-shard': None} + self.backend_no_pass(host, self.props) + + def test_sharding_enabled_any_backend_match(self): + self.filt_cls._PROJECT_SHARD_CACHE['baz'] = ['sharding_enabled'] + self.props['request_spec']['volume_properties']['project_id'] = 'baz' + caps = {'vcenter-shard': 'vc-a-0'} + host = fakes.FakeBackendState('host1', + {'capabilities': caps, + 'vendor_name': VMWARE_VENDOR}) + self.backend_passes(host, self.props) + + def test_sharding_enabled_and_single_shard_any_backend_match(self): + self.filt_cls._PROJECT_SHARD_CACHE['baz'] = ['sharding_enabled', + 'vc-a-1'] + self.props['request_spec']['volume_properties']['project_id'] = 'baz' + caps = {'vcenter-shard': 'vc-a-0'} + host = fakes.FakeBackendState('host1', + {'capabilities': caps, + 'vendor_name': VMWARE_VENDOR}) + self.backend_passes(host, self.props) + + def test_scheduler_hints_override_sharding_enabled(self): + self.filt_cls._PROJECT_SHARD_CACHE['baz'] = ['sharding_enabled'] + self.props['scheduler_hints'] = {'vcenter-shard': 'vc-a-1'} + self.props['request_spec']['volume_properties']['project_id'] = 'baz' + caps0 = {'vcenter-shard': 'vc-a-0'} + host = fakes.FakeBackendState('host0', + {'capabilities': caps0, + 'vendor_name': VMWARE_VENDOR}) + self.backend_no_pass(host, self.props) + caps1 = {'vcenter-shard': 'vc-a-1'} + host = fakes.FakeBackendState('host1', + {'capabilities': caps1, + 'vendor_name': VMWARE_VENDOR}) + self.backend_passes(host, self.props) + + def test_noop_for_find_backend_by_connector_with_hint(self): + """Check if we pass any backend + + If the operation we're scheduling for is find_backend_for_connector, + we do not look at the shards but pass through every backend, because + this tries to move a volume towards where a server is during attach and + we always want that to succeed. Shards are supposed to help decision + making when we don't know where the volume will be attached. + """ + caps = {'vcenter-shard': 'vc-a-0'} + host = fakes.FakeBackendState('host1', + {'capabilities': caps, + 'vendor_name': VMWARE_VENDOR}) + self.props['scheduler_hints'] = {'vcenter-shard': 'vc-a-1'} + self.props['request_spec']['operation'] = 'find_backend_for_connector' + self.backend_passes(host, self.props) + + def test_noop_for_find_backend_by_connector_without_hint(self): + """Check if we pass any backend + + If the operation we're scheduling for is find_backend_for_connector, + we do not look at the shards but pass through every backend, because + this tries to move a volume towards where a server is during attach and + we always want that to succeed. Shards are supposed to help decision + making when we don't know where the volume will be attached. + """ + self.filt_cls._PROJECT_SHARD_CACHE['baz'] = ['vc-a-1'] + caps = {'vcenter-shard': 'vc-a-0'} + host = fakes.FakeBackendState('host1', + {'capabilities': caps, + 'vendor_name': VMWARE_VENDOR}) + self.props['request_spec']['operation'] = 'find_backend_for_connector' + self.backend_passes(host, self.props) + + @mock.patch('cinder.context.get_admin_context') + @mock.patch('cinder.db.get_hosts_by_volume_metadata') + def test_same_shard_for_k8s_volumes(self, mock_get_hosts, + mock_get_context): + CSI_KEY = 'cinder.csi.openstack.org/cluster' + all_backends = [ + fakes.FakeBackendState( + 'volume-vc-a-0@backend#pool1', + {'capabilities': {'vcenter-shard': 'vc-a-0'}, + 'vendor_name': VMWARE_VENDOR}), + fakes.FakeBackendState( + 'volume-vc-a-1@backend#pool2', + {'capabilities': {'vcenter-shard': 'vc-a-1'}, + 'vendor_name': VMWARE_VENDOR}), + ] + mock_get_context.return_value = self.context + fake_meta = { + CSI_KEY: 'cluster-1', + } + mock_get_hosts.return_value = [('volume-vc-x-1', 2), + ('volume-vc-a-1', 1)] + self.filt_cls._PROJECT_SHARD_CACHE['baz'] = ['sharding_enabled', + 'vc-a-1'] + filter_props = dict(self.props) + filter_props['request_spec']['volume_properties'].update({ + 'project_id': 'baz', + 'metadata': fake_meta + }) + filter_props['availability_zone'] = 'az-1' + + filtered = self.filt_cls.filter_all(all_backends, filter_props) + + mock_get_hosts.assert_called_once_with( + key=CSI_KEY, value=fake_meta[CSI_KEY], filters={ + 'availability_zone': 'az-1' + }) + self.assertEqual(len(filtered), 1) + self.assertEqual('volume-vc-a-1@backend#pool2', filtered[0].host) + + def backend_passes(self, backend, filter_properties): + filtered = self.filt_cls.filter_all([backend], filter_properties) + self.assertEqual(backend, filtered[0]) + + def backend_no_pass(self, backend, filter_properties): + filtered = self.filt_cls.filter_all([backend], filter_properties) + self.assertEqual(0, len(filtered)) diff --git a/cinder/tests/unit/volume/drivers/test_datera.py b/cinder/tests/unit/volume/drivers/test_datera.py index de7eb4f5c52..1d28a3200c3 100644 --- a/cinder/tests/unit/volume/drivers/test_datera.py +++ b/cinder/tests/unit/volume/drivers/test_datera.py @@ -20,7 +20,6 @@ from cinder import context from cinder import exception from cinder.tests.unit import test -from cinder import version from cinder.volume import configuration as conf from cinder.volume import volume_types @@ -430,21 +429,13 @@ def test_get_manageable_volumes(self): offset = mock.MagicMock() sort_keys = mock.MagicMock() sort_dirs = mock.MagicMock() - if (version.version_string() >= '15.0.0'): - with mock.patch( - 'cinder.volume.volume_utils.paginate_entries_list') \ - as mpage: - self.driver.get_manageable_volumes( - [testvol], marker, limit, offset, sort_keys, sort_dirs) - mpage.assert_called_once_with( - [v1, v2], marker, limit, offset, sort_keys, sort_dirs) - else: - with mock.patch( - 'cinder.volume.utils.paginate_entries_list') as mpage: - self.driver.get_manageable_volumes( - [testvol], marker, limit, offset, sort_keys, sort_dirs) - mpage.assert_called_once_with( - [v1, v2], marker, limit, offset, sort_keys, sort_dirs) + with mock.patch( + 'cinder.volume.volume_utils.paginate_entries_list') \ + as mpage: + self.driver.get_manageable_volumes( + [testvol], marker, limit, offset, sort_keys, sort_dirs) + mpage.assert_called_once_with( + [v1, v2], marker, limit, offset, sort_keys, sort_dirs) def test_unmanage(self): testvol = _stub_volume() diff --git a/cinder/tests/unit/volume/drivers/vmware/test_fcd.py b/cinder/tests/unit/volume/drivers/vmware/test_fcd.py index 552c2291cff..28d78c8e8db 100644 --- a/cinder/tests/unit/volume/drivers/vmware/test_fcd.py +++ b/cinder/tests/unit/volume/drivers/vmware/test_fcd.py @@ -44,6 +44,7 @@ class VMwareVStorageObjectDriverTestCase(test.TestCase): IP = 'localhost' PORT = 2321 IMG_TX_TIMEOUT = 10 + MAX_OBJECTS = 100 RESERVED_PERCENTAGE = 0 VMDK_DRIVER = vmdk.VMwareVcVmdkDriver FCD_DRIVER = fcd.VMwareVStorageObjectDriver @@ -65,7 +66,11 @@ def setUp(self): self._config.vmware_host_ip = self.IP self._config.vmware_host_port = self.PORT self._config.vmware_image_transfer_timeout_secs = self.IMG_TX_TIMEOUT + self._config.vmware_max_objects_retrieval = self.MAX_OBJECTS + self._config.vmware_storage_profile = None self._config.reserved_percentage = self.RESERVED_PERCENTAGE + self._config.vmware_datastores_as_pools = False + self._config.vmware_snapshot_format = "COW" self._driver = fcd.VMwareVStorageObjectDriver( configuration=self._config) self._driver._vc_version = self.VC_VERSION @@ -84,39 +89,22 @@ def test_do_setup(self, vops, vmdk_do_setup): self.assertTrue(self._driver._use_fcd_snapshot) self.assertTrue(self._driver._storage_policy_enabled) - @mock.patch.object(VMDK_DRIVER, 'volumeops') - @mock.patch.object(VMDK_DRIVER, '_get_datastore_summaries') - def test_get_volume_stats(self, _get_datastore_summaries, vops): - FREE_GB = 7 - TOTAL_GB = 11 - - class ObjMock(object): - def __init__(self, **kwargs): - self.__dict__.update(kwargs) - - _get_datastore_summaries.return_value = \ - ObjMock(objects= [ - ObjMock(propSet = [ - ObjMock(name = "host", - val = ObjMock(DatastoreHostMount = [])), - ObjMock(name = "summary", - val = ObjMock(freeSpace = FREE_GB * units.Gi, - capacity = TOTAL_GB * units.Gi, - accessible = True)) - ]) - ]) - - vops._in_maintenance.return_value = False - + @mock.patch.object(VMDK_DRIVER, 'session') + def test_get_volume_stats(self, session): + 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' stats = self._driver.get_volume_stats() self.assertEqual('VMware', stats['vendor_name']) self.assertEqual(self._driver.VERSION, stats['driver_version']) self.assertEqual(self._driver.STORAGE_TYPE, stats['storage_protocol']) - self.assertEqual(self.RESERVED_PERCENTAGE, - stats['reserved_percentage']) - self.assertEqual(TOTAL_GB, stats['total_capacity_gb']) - self.assertEqual(FREE_GB, stats['free_capacity_gb']) + self.assertEqual(0, stats['reserved_percentage']) + self.assertEqual(0, stats['total_capacity_gb']) + self.assertEqual(0, stats['free_capacity_gb']) + self.assertEqual(vmdk.LOCATION_DRIVER_NAME + ":fake-service", + stats['location_info']) def _create_volume_dict(self, vol_id=VOL_ID, @@ -173,15 +161,18 @@ def _test_get_temp_image_folder( host = mock.sentinel.host summary = mock.Mock() summary.name = 'ds-1' + volume = self._create_volume_obj() select_datastore.return_value = (host, mock.ANY, summary) dc_ref = mock.sentinel.dc_ref vops.get_dc.return_value = dc_ref size_bytes = units.Gi - ret = self._driver._get_temp_image_folder(size_bytes, preallocated) + ret = self._driver._get_temp_image_folder(volume.name, + size_bytes, + preallocated) self.assertEqual( - (dc_ref, summary, vmdk.TMP_IMAGES_DATASTORE_FOLDER_PATH), ret) + (dc_ref, summary, volume.name + '/'), ret) exp_req = {hub.DatastoreSelector.SIZE_BYTES: size_bytes} if preallocated: exp_req[hub.DatastoreSelector.HARD_AFFINITY_DS_TYPE] = ( @@ -191,7 +182,7 @@ def _test_get_temp_image_folder( select_datastore.assert_called_once_with(exp_req) vops.get_dc.assert_called_once_with(host) vops.create_datastore_folder.assert_called_once_with( - summary.name, vmdk.TMP_IMAGES_DATASTORE_FOLDER_PATH, dc_ref) + summary.name, volume.name + '/', dc_ref) def test_get_temp_image_folder(self): self._test_get_temp_image_folder() @@ -238,7 +229,8 @@ def test_create_volume(self, vops, get_storage_profile_id, get_disk_type, select_ds_fcd.assert_called_once_with(volume) get_disk_type.assert_called_once_with(volume) vops.create_fcd.assert_called_once_with( - volume.name, volume.size * units.Ki, ds_ref, disk_type, + volume.id, volume.name, volume.size * units.Ki, + ds_ref, disk_type, profile_id=profile_id) @mock.patch.object(volumeops.FcdLocation, 'from_provider_location') @@ -369,7 +361,8 @@ def test_copy_image_to_volume(self, self._context, volume, image_service, image_id) self.assertEqual({'provider_location': provider_location}, ret) - get_temp_image_folder.assert_called_once_with(volume.size * units.Gi) + get_temp_image_folder.assert_called_once_with(volume.name, + volume.size * units.Gi) if disk_type == vmdk.ImageDiskType.PREALLOCATED: create_disk_from_preallocated_image.assert_called_once_with( self._context, image_service, image_id, image_meta['size'], @@ -482,8 +475,8 @@ def test_clone_fcd(self, vops, from_provider_loc): name = mock.sentinel.name dest_ds_ref = mock.sentinel.dest_ds_ref disk_type = mock.sentinel.disk_type - ret = self._driver._clone_fcd( - provider_loc, name, dest_ds_ref, disk_type) + ret = self._driver._clone_fcd(provider_loc, + name, dest_ds_ref, disk_type) self.assertEqual(dest_fcd_loc, ret) from_provider_loc.assert_called_once_with(provider_loc) vops.clone_fcd.assert_called_once_with( @@ -594,18 +587,14 @@ def test_create_volume_from_fcd( get_disk_type, select_ds_fcd): ds_ref = mock.sentinel.ds_ref select_ds_fcd.return_value = ds_ref - disk_type = mock.sentinel.disk_type get_disk_type.return_value = disk_type - profile_id = mock.sentinel.profile_id get_storage_profile_id.return_value = profile_id - cloned_fcd_loc = mock.Mock() dest_provider_loc = mock.sentinel.dest_provider_loc cloned_fcd_loc.provider_location.return_value = dest_provider_loc clone_fcd.return_value = cloned_fcd_loc - provider_loc = mock.sentinel.provider_loc cur_size = 1 volume = self._create_volume_obj() @@ -614,9 +603,9 @@ def test_create_volume_from_fcd( self.assertEqual({'provider_location': dest_provider_loc}, ret) select_ds_fcd.test_assert_called_once_with(volume) get_disk_type.test_assert_called_once_with(volume) - clone_fcd.assert_called_once_with( - provider_loc, volume.name, ds_ref, disk_type=disk_type, - profile_id=profile_id) + clone_fcd.assert_called_once_with(provider_loc, volume, + ds_ref, disk_type=disk_type, + profile_id=profile_id) extend_if_needed.assert_called_once_with( cloned_fcd_loc, cur_size, volume.size) @@ -651,7 +640,7 @@ def _test_create_volume_from_snapshot( if use_fcd_snapshot: self.assertEqual({'provider_location': provider_loc}, ret) vops.create_fcd_from_snapshot.assert_called_once_with( - fcd_snap_loc, volume.name, profile_id=profile_id) + fcd_snap_loc, volume.name, volume.id, profile_id=profile_id) extend_if_needed.assert_called_once_with( fcd_loc, snapshot.volume_size, volume.size) else: diff --git a/cinder/tests/unit/volume/drivers/vmware/test_remote.py b/cinder/tests/unit/volume/drivers/vmware/test_remote.py new file mode 100644 index 00000000000..3942a1685f0 --- /dev/null +++ b/cinder/tests/unit/volume/drivers/vmware/test_remote.py @@ -0,0 +1,123 @@ +# Copyright (c) 2020 SAP SE +# All Rights Reserved. +# +# 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 unittest import mock + +from cinder.tests.unit import fake_volume +from cinder.tests.unit import test +from cinder.volume.drivers.vmware import remote as vmware_remote + + +class VmdkDriverRemoteApiTest(test.RPCAPITestCase): + + def setUp(self): + super(VmdkDriverRemoteApiTest, self).setUp() + self.rpcapi = vmware_remote.VmdkDriverRemoteApi + self.base_version = \ + vmware_remote.VmdkDriverRemoteApi.RPC_DEFAULT_VERSION + self._fake_host = 'fake_host' + self._fake_volume = fake_volume.fake_db_volume() + + def test_get_service_locator_info(self): + self._test_rpc_api('get_service_locator_info', + rpc_method='call', + server=self._fake_host, + host=self._fake_host) + + def test_select_ds_for_volume(self): + self._test_rpc_api('select_ds_for_volume', + rpc_method='call', + server=self._fake_host, + cinder_host=self._fake_host, + volume=self._fake_volume) + + def test_move_backing_to_folder(self): + self._test_rpc_api('move_volume_backing_to_folder', + rpc_method='call', + server=self._fake_host, + host=self._fake_host, + volume=self._fake_volume, + folder='fake-folder') + + def test_create_backing(self): + self._test_rpc_api('create_backing', + rpc_method='call', + server=self._fake_host, + host=self._fake_host, + volume=self._fake_volume, + create_params=None, + cinder_host=self._fake_host, + ) + + +class VmdkDriverRemoteServiceTest(test.TestCase): + + def setUp(self): + super(VmdkDriverRemoteServiceTest, self).setUp() + self._volumeops = mock.Mock() + self._driver = mock.Mock( + volumeops=self._volumeops, + service_locator_info=mock.sentinel.service_locator) + self._service = vmware_remote.VmdkDriverRemoteService(self._driver) + self._ctxt = mock.Mock() + self._fake_volume = fake_volume.fake_db_volume() + + def test_get_service_locator_info(self): + ret_val = self._service.get_service_locator_info(self._ctxt) + self.assertEqual(mock.sentinel.service_locator, ret_val) + + def test_select_ds_for_volume(self): + fake_host = mock.Mock(value='fake-host') + fake_rp = mock.Mock(value='fake-rp') + fake_folder = mock.Mock(value='fake-folder') + fake_summary = mock.Mock(datastore=mock.Mock(vlaue='fake-ds')) + fake_profile_id = 'fake-uuid' + + self._driver._select_ds_for_volume.return_value = \ + (fake_host, fake_rp, fake_folder, fake_summary) + self._driver._get_storage_profile_id.return_value = \ + fake_profile_id + ret_val = self._service.select_ds_for_volume(self._ctxt, + self._fake_volume) + self._driver._select_ds_for_volume.assert_called_once_with( + self._fake_volume, cinder_host=None) + self.assertEqual({ + 'host': fake_host.value, + 'resource_pool': fake_rp.value, + 'folder': fake_folder.value, + 'profile_id': fake_profile_id, + 'datastore': fake_summary.datastore.value, + }, ret_val) + + @mock.patch('oslo_vmware.vim_util.get_moref') + def test_move_volume_backing_to_folder(self, get_moref): + fake_backing = mock.Mock(value='fake-backing') + folder_name = 'fake-folder' + fake_folder = mock.Mock(value=folder_name) + get_moref.return_value = fake_folder + self._volumeops.get_backing.return_value = fake_backing + + self._service.move_volume_backing_to_folder( + self._ctxt, self._fake_volume, folder_name) + + self._volumeops.get_backing.assert_called_once_with( + self._fake_volume['name'], self._fake_volume['id']) + get_moref.assert_called_once_with(folder_name, 'Folder') + self._volumeops.move_backing_to_folder.assert_called_once_with( + fake_backing, fake_folder) + + def test_create_backing(self): + self._service.create_backing(self._ctxt, self._fake_volume) + self._driver._create_backing.assert_called_once_with( + self._fake_volume, create_params=None, cinder_host=None) diff --git a/cinder/tests/unit/volume/drivers/vmware/test_vmware_datastore.py b/cinder/tests/unit/volume/drivers/vmware/test_vmware_datastore.py index 403d3b79de8..1cd75e0c851 100644 --- a/cinder/tests/unit/volume/drivers/vmware/test_vmware_datastore.py +++ b/cinder/tests/unit/volume/drivers/vmware/test_vmware_datastore.py @@ -252,10 +252,9 @@ def mock_get_host_properties(host_ref): in_maintenance = False else: in_maintenance = True - runtime = mock.Mock(spec=['connectionState', 'inMaintenanceMode']) - runtime.connectionState = 'connected' - runtime.inMaintenanceMode = in_maintenance - return {'parent': cluster_ref, 'runtime': runtime} + return {'parent': cluster_ref, + 'runtime.connectionState': 'connected', + 'runtime.inMaintenanceMode': in_maintenance} get_host_props.side_effect = mock_get_host_properties @@ -298,17 +297,23 @@ def mock_is_usable(mount_info): def test_select_best_datastore_with_empty_datastores(self): self.assertIsNone(self._ds_sel._select_best_datastore({})) + @mock.patch('cinder.volume.drivers.vmware.datastore.DatastoreSelector.' + '_is_host_usable') + @mock.patch('cinder.volume.drivers.vmware.datastore.DatastoreSelector.' + 'is_host_in_buildup_cluster') @mock.patch('cinder.volume.drivers.vmware.datastore.DatastoreSelector.' 'get_profile_id') @mock.patch('cinder.volume.drivers.vmware.datastore.DatastoreSelector.' '_get_datastores') + @mock.patch('cinder.volume.drivers.vmware.datastore.DatastoreSelector.' + '_filter_hosts') @mock.patch('cinder.volume.drivers.vmware.datastore.DatastoreSelector.' '_filter_datastores') @mock.patch('cinder.volume.drivers.vmware.datastore.DatastoreSelector.' '_select_best_datastore') def test_select_datastore( - self, select_best_datastore, filter_datastores, get_datastores, - get_profile_id): + self, select_best_datastore, filter_datastores, filter_hosts, + get_datastores, get_profile_id, is_buildup, is_usable): profile_id = mock.sentinel.profile_id get_profile_id.return_value = profile_id @@ -316,12 +321,18 @@ def test_select_datastore( datastores = mock.sentinel.datastores get_datastores.return_value = datastores + filtered_hosts = mock.sentinel.filtered_hosts + filter_hosts.return_value = filtered_hosts + filtered_datastores = mock.sentinel.filtered_datastores filter_datastores.return_value = filtered_datastores best_datastore = mock.sentinel.best_datastore select_best_datastore.return_value = best_datastore + is_buildup.return_value = False + is_usable.return_value = True + size_bytes = 1024 req = {self._ds_sel.SIZE_BYTES: size_bytes} aff_ds_types = [ds_sel.DatastoreType.VMFS] @@ -331,15 +342,16 @@ def test_select_datastore( profile_name = mock.sentinel.profile_name req[ds_sel.DatastoreSelector.PROFILE_NAME] = profile_name - hosts = mock.sentinel.hosts + hosts = [mock.sentinel.hosts] self.assertEqual(best_datastore, self._ds_sel.select_datastore(req, hosts)) get_datastores.assert_called_once_with() + filter_hosts.assert_called_once_with(hosts) filter_datastores.assert_called_once_with( datastores, size_bytes, profile_id, anti_affinity_ds, aff_ds_types, - valid_host_refs=hosts) - select_best_datastore.assert_called_once_with(filtered_datastores, - valid_host_refs=hosts) + valid_host_refs=filtered_hosts) + select_best_datastore.assert_called_once_with( + filtered_datastores, valid_host_refs=filtered_hosts) @mock.patch('cinder.volume.drivers.vmware.datastore.DatastoreSelector.' 'get_profile_id') 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 cae4666722c..adeca7d5403 100644 --- a/cinder/tests/unit/volume/drivers/vmware/test_vmware_vmdk.py +++ b/cinder/tests/unit/volume/drivers/vmware/test_vmware_vmdk.py @@ -90,6 +90,7 @@ class VMwareVcVmdkDriverTestCase(test.TestCase): IMAGE_ID = 'eb87f4b0-d625-47f8-bb45-71c43b486d3a' IMAGE_NAME = 'image-1' ADAPTER_TYPE = volumeops.VirtualDiskAdapterType.BUS_LOGIC + STORAGE_PROFILE = 'gold' def setUp(self): super(VMwareVcVmdkDriverTestCase, self).setUp() @@ -115,51 +116,142 @@ def setUp(self): vmware_snapshot_format=self.SNAPSHOT_FORMAT, vmware_lazy_create=True, vmware_datastore_regex=None, - reserved_percentage=0 + reserved_percentage=0, + vmware_profile_check_on_attach=True, + vmware_storage_profile=[self.STORAGE_PROFILE], + vmware_select_random_best_datastore=False, + vmware_random_datastore_range=None, + vmware_datastores_as_pools=False, + allow_pulling_images_from_url=False, ) self._db = mock.Mock() self._driver = vmdk.VMwareVcVmdkDriver(configuration=self._config, + additional_endpoints=[], db=self._db) self._context = context.get_admin_context() self.updated_at = timeutils.utcnow() - @mock.patch.object(VMDK_DRIVER, 'volumeops') - @mock.patch.object(VMDK_DRIVER, '_get_datastore_summaries') - def test_get_volume_stats(self, _get_datastore_summaries, vops): - FREE_GB = 7 - TOTAL_GB = 11 + @mock.patch.object(VMDK_DRIVER, 'session') + @mock.patch('oslo_vmware.pbm.get_profile_id_by_name') + def test_check_for_setup_error(self, get_profile_id_by_name, session): + profile_id = mock.sentinel.profile_id + get_profile_id_by_name.return_value = mock.Mock(uniqueId=profile_id) + self._driver._storage_policy_enabled = True + + # set config + self._driver.check_for_setup_error() + get_profile_id_by_name.assert_called_once_with(session, + self.STORAGE_PROFILE) - class ObjMock(object): - def __init__(self, **kwargs): - self.__dict__.update(kwargs) + @mock.patch.object(VMDK_DRIVER, 'session') + def test_check_for_setup_error_no_profile(self, session): + self._driver._storage_policy_enabled = True + + self._config.vmware_storage_profile = None + self._driver.check_for_setup_error() + + self._config.vmware_storage_profile = [] + self._driver.check_for_setup_error() - _get_datastore_summaries.return_value = (ObjMock(objects= [ - ObjMock(propSet = [ - ObjMock(name = "host", - val = ObjMock(DatastoreHostMount = [])), - ObjMock(name = "summary", - val = ObjMock(freeSpace = FREE_GB * units.Gi, - capacity = TOTAL_GB * units.Gi, - accessible = True)) - ]) - ])) + @mock.patch.object(VMDK_DRIVER, 'session') + @mock.patch('oslo_vmware.pbm.get_profile_id_by_name') + def test_check_for_setup_error_fail(self, get_profile_id_by_name, session): + get_profile_id_by_name.return_value = None + self._driver._storage_policy_enabled = True - vops._in_maintenance.return_value = False - # Enable volume stats collection from backend - self._driver.configuration.vmware_enable_volume_stats = True + # set config + self.assertRaises(cinder_exceptions.InvalidInput, + self._driver.check_for_setup_error) + get_profile_id_by_name.assert_called_once_with(session, + self.STORAGE_PROFILE) + @mock.patch.object(VMDK_DRIVER, 'session') + def test_get_volume_stats_no_pools(self, session): + 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' stats = self._driver.get_volume_stats() self.assertEqual('VMware', stats['vendor_name']) self.assertEqual(self._driver.VERSION, stats['driver_version']) self.assertEqual('vmdk', stats['storage_protocol']) - self.assertEqual(self._config.reserved_percentage, - stats['reserved_percentage']) - self.assertEqual(TOTAL_GB, stats['total_capacity_gb']) - self.assertEqual(FREE_GB, stats['free_capacity_gb']) - self.assertFalse(stats['shared_targets']) + self.assertEqual(0, stats['reserved_percentage']) + self.assertEqual(0, stats['total_capacity_gb']) + self.assertEqual(0, stats['free_capacity_gb']) + self.assertEqual(vmdk.LOCATION_DRIVER_NAME + ":fake-service", + stats['location_info']) + + def _fake_stats_result(self): + ds_attrs = { + "value": "datastore-85", + } + ds = mock.Mock(**ds_attrs) + + summary_attrs = { + "datastore": ds, + "name": "datastore2", + "url": "ds:///vmfs/volumes/5ed14b0e-ee8de32d-2014-525400b59848/", + "capacity": 10000000000000, + "freeSpace": 5000000000000, + "uncommitted": 5000000000000, + "accessible": True, + "multipleHostAccess": False, + "type": "VMFS", + "maintenanceMode": "normal", + } + summary = mock.Mock(**summary_attrs) + summary.name = "datastore2" + + propset_attrs = {"val": summary} + _propSet = mock.MagicMock(**propset_attrs) + + class props(object): + propSet = [_propSet] + + class result(object): + objects = [props()] + + datastores = {"datastore-85": {"summary": summary, + "storage_profile": "Gold"}} + return result(), datastores + + @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): + 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._driver = vmdk.VMwareVcVmdkDriver(configuration=self._config, + additional_endpoints=[], + db=self._db) + self._driver._ds_sel = mock.MagicMock() + + 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' + stats = self._driver.get_volume_stats() + + self.assertEqual('VMware', stats['vendor_name']) + self.assertEqual(self._driver.VERSION, stats['driver_version']) + self.assertEqual('vmdk', stats['storage_protocol']) + self.assertIn('pools', stats) + self.assertEqual(1, len(stats["pools"])) + 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('up', stats["pools"][0]['pool_state']) + self.assertEqual('up', stats["backend_state"]) + self.assertFalse(stats["pools"][0]['multiattach']) + self.assertEqual(vmdk.LOCATION_DRIVER_NAME + ":fake-service", + stats['location_info']) def test_test_volume_stats_disabled(self): RESERVED_PERCENTAGE = 0 @@ -187,6 +279,7 @@ def _create_volume_dict(self, attachment=None, project_id=PROJECT_ID): return {'id': vol_id, + 'name_id': vol_id, 'display_name': display_name, 'name': 'volume-%s' % vol_id, 'volume_type_id': volume_type_id, @@ -244,11 +337,13 @@ def test_create_volume_with_lazy_create_disabled(self, create_backing): @mock.patch.object(VMDK_DRIVER, 'volumeops') def test_delete_volume_without_backing(self, vops): vops.get_backing.return_value = None + vops.get_backing_by_uuid.return_value = None - volume = self._create_volume_dict() + volume = self._create_volume_obj() self._driver.delete_volume(volume) vops.get_backing.assert_called_once_with(volume['name'], volume['id']) + vops.get_backing_by_uuid.assert_called_once_with(volume['name_id']) self.assertFalse(vops.delete_backing.called) @mock.patch.object(VMDK_DRIVER, 'volumeops') @@ -327,7 +422,8 @@ def _create_snapshot_dict(self, 'name': name, 'display_description': description, 'volume_size': volume['size'], - 'provider_location': provider_location + 'provider_location': provider_location, + 'metadata': {}, } @mock.patch.object(VMDK_DRIVER, 'volumeops') @@ -474,7 +570,7 @@ def test_create_snapshot_template( vops.get_backing.assert_called_once_with(snapshot['volume_name'], snapshot['volume']['id']) create_snapshot_template_format.assert_called_once_with( - snapshot, backing) + snapshot, backing, backend=None) @mock.patch.object(VMDK_DRIVER, 'volumeops') def test_get_template_by_inv_path(self, vops): @@ -1180,6 +1276,7 @@ def _test_copy_image_to_volume_stream_optimized(self, context = mock.sentinel.context volume = self._create_volume_dict(size=3) + volume_size_kb = 3 * units.Gi / units.Ki image_service = mock.sentinel.image_service image_id = mock.sentinel.image_id image_size = 2 * units.Gi @@ -1198,8 +1295,9 @@ def _test_copy_image_to_volume_stream_optimized(self, select_ds_for_volume.assert_called_once_with(volume) vops.get_create_spec.assert_called_once_with( - volume['name'], 0, disk_type, summary.name, profile_id=profile_id, - adapter_type=adapter_type, extra_config=extra_config) + volume['name'], volume_size_kb, disk_type, summary.name, + profile_id=profile_id, adapter_type=adapter_type, + extra_config=extra_config) self.assertEqual(vm_create_spec, import_spec.configSpec) download_image.assert_called_with( context, @@ -1213,7 +1311,8 @@ def _test_copy_image_to_volume_stream_optimized(self, vm_folder=folder, vm_import_spec=import_spec, image_size=image_size, - http_method='POST') + http_method='POST', + allow_pull_from_url=False) if download_error: self.assertFalse(vops.update_backing_disk_uuid.called) vops.delete_backing.assert_called_once_with(backing) @@ -1841,17 +1940,14 @@ def _test_do_setup( mock_session.pbm_wsdl_loc_set.assert_called_once_with(pbm_wsdl) self.assertEqual(enable_pbm, self._driver._storage_policy_enabled) register_extension.assert_called_once() - vops_cls.assert_called_once_with( - session, - self._driver.configuration.vmware_max_objects_retrieval, - vmdk.EXTENSION_KEY, - vmdk.EXTENSION_TYPE) - self.assertEqual(vops_cls.return_value, self._driver._volumeops) + cfg = self._driver.configuration ds_sel_cls.assert_called_once_with( vops, session, self._driver.configuration.vmware_max_objects_retrieval, - ds_regex=ds_regex) + ds_regex=ds_regex, + random_ds=cfg.vmware_select_random_best_datastore, + random_ds_range=cfg.vmware_random_datastore_range) self.assertEqual(ds_sel_cls.return_value, self._driver._ds_sel) vops.get_cluster_refs.assert_called_once_with( self._driver.configuration.vmware_cluster_name) @@ -1958,14 +2054,18 @@ def test_select_ds_for_volume( @mock.patch.object(VMDK_DRIVER, 'volumeops') @mock.patch.object(VMDK_DRIVER, '_get_storage_profile_id') + @mock.patch.object(VMDK_DRIVER, '_get_connection_import_data') def _test_get_connection_info( - self, get_storage_profile_id, vops, vmdk_connector=False): - volume = self._create_volume_obj() + self, get_connection_import_data, get_storage_profile_id, vops, + vmdk_connector=False): + volume = self._create_volume_obj(status='restoring-backup') backing = mock.Mock(value='ref-1') profile_id = mock.sentinel.profile_id get_storage_profile_id.return_value = profile_id + get_connection_import_data.return_value = {'folder': 'folder-1'} + if vmdk_connector: vmdk_path = mock.sentinel.vmdk_path vops.get_vmdk_path.return_value = vmdk_path @@ -1993,6 +2093,7 @@ def _test_get_connection_info( self.assertEqual(vmdk_path, ret['data']['vmdk_path']) self.assertEqual('ds-1', ret['data']['datastore']) self.assertEqual('dc-1', ret['data']['datacenter']) + self.assertEqual('folder-1', ret['data']['import_data']['folder']) config = self._driver.configuration exp_config = { @@ -2016,6 +2117,77 @@ def test_get_connection_info(self): def test_get_connection_info_vmdk_connector(self): self._test_get_connection_info(vmdk_connector=True) + @mock.patch('cinder.volume.drivers.vmware.volumeops.ControllerType') + @mock.patch.object(VMDK_DRIVER, 'volumeops') + @mock.patch.object(VMDK_DRIVER, '_select_ds_for_volume') + @mock.patch.object(VMDK_DRIVER, '_get_extra_config') + @mock.patch.object(VMDK_DRIVER, '_get_adapter_type') + @mock.patch.object(VMDK_DRIVER, '_get_disk_type') + def test_get_connection_import_data(self, get_disk_type, get_adapter_type, + get_extra_config, + select_ds_for_volume, vops, + controller_type): + volume = self._create_volume_obj(size=1) + folder = mock.Mock(value=mock.Mock()) + rp = mock.Mock(value=mock.Mock()) + mock_data = { + 'folder': folder.value, + 'resource_pool': rp.value, + 'vm': { + 'path_name': '[ds-1]', + 'guest_id': volumeops.VM_GUEST_ID, + 'num_cpus': volumeops.VM_NUM_CPUS, + 'memory_mb': volumeops.VM_MEMORY_MB, + 'vmx_version': volumeops.VMX_VERSION, + 'extension_key': 'foo-extension-key', + 'extension_type': 'foo-extension-type', + 'extra_config': {} + }, + 'adapter_type': mock.Mock(), + 'controller': { + 'type': 'controllerTypeOne', + 'key': 1, + 'create': True, + 'shared_bus': 'shared', + 'bus_number': volumeops.CONTROLLER_DEVICE_BUS_NUMBER + }, + 'disk': { + 'type': 'diskTypeOne', + 'key': -101, + 'capacity_in_kb': 1024 * 1024, + 'eagerly_scrub': False, + 'thin_provisioned': True + } + } + + controller_type.get_controller_type.return_value = mock_data[ + 'controller']['type'] + vops.get_controller_key_and_spec.return_value = \ + (mock_data['controller']['key'], mock.Mock()) + vops.get_vm_path_name.return_value = mock_data['vm']['path_name'] + vops.get_vmx_version.return_value = mock_data['vm']['vmx_version'] + vops._extension_key = mock_data['vm']['extension_key'] + vops._extension_type = mock_data['vm']['extension_type'] + vops.get_controller_device_shared_bus.return_value = mock_data[ + 'controller']['shared_bus'] + vops.get_disk_device_key.return_value = mock_data['disk']['key'] + vops.get_disk_capacity_in_kb.return_value = mock_data['disk'][ + 'capacity_in_kb'] + vops.get_disk_eagerly_scrub.return_value = mock_data['disk'][ + 'eagerly_scrub'] + vops.get_disk_thin_provisioned.return_value = mock_data['disk'][ + 'thin_provisioned'] + + get_disk_type.return_value = mock_data['disk']['type'] + select_ds_for_volume.return_value = (mock.Mock(), rp, folder, + mock.Mock()) + get_extra_config.return_value = mock_data['vm']['extra_config'] + get_adapter_type.return_value = mock_data['adapter_type'] + + data = self._driver._get_connection_import_data(volume) + + self.assertEqual(mock_data, data) + @mock.patch.object(VMDK_DRIVER, 'volumeops') @mock.patch('oslo_vmware.vim_util.get_moref') @mock.patch.object(VMDK_DRIVER, '_create_backing') @@ -2081,6 +2253,28 @@ def test_initialize_connection_with_no_instance_and_no_backing(self): def test_initialize_connection_with_no_instance_and_backing(self): self._test_initialize_connection(instance_exists=False) + def test_initialize_connection_connector_rejected(self): + connector = {'ip': '0.0.0.0', + "connection_capabilities": {}} + volume = self._create_volume_obj() + + self._driver._vcenter_instance_uuid_cache = "11234" + + self.assertRaises(cinder_exceptions.ConnectorRejected, + self._driver.initialize_connection, + volume, connector) + + @mock.patch.object(VMDK_DRIVER, 'volumeops') + def test_terminate_connection(self, vops): + volume = self._create_volume_obj(status='restoring-backup') + connector = {'platform': 'amd64', 'os_type': 'linux'} + backing = mock.Mock() + vops.get_backing_by_uuid.return_value = backing + self._driver.terminate_connection(volume, connector) + vops.get_backing_by_uuid.assert_called_once_with(volume['id']) + vops.rename_backing.assert_called_once_with(backing, volume['name']) + vops.update_backing_disk_uuid(backing, volume['id']) + @mock.patch.object(VMDK_DRIVER, 'volumeops') def _test_get_volume_group_folder(self, vops, snapshot=False): folder = mock.sentinel.folder @@ -2246,6 +2440,10 @@ def test_create_volume_from_template( tmp_backing = mock.sentinel.tmp_backing vops.clone_backing.return_value = tmp_backing + disk_device = mock.sentinel.disk_device + disk_device.capacityInKB = self.VOL_SIZE + 11111111 + vops._get_disk_device.return_value = disk_device + volume = self._create_volume_obj() inv_path = mock.sentinel.inv_path self._driver._create_volume_from_template(volume, inv_path) @@ -2261,7 +2459,8 @@ def test_create_volume_from_template( disk_type=disk_type, host=host, resource_pool=rp, - folder=folder) + folder=folder, + device_changes=None) create_volume_from_temp_backing.assert_called_once_with(volume, tmp_backing) @@ -2368,6 +2567,10 @@ def test_create_temp_backing_from_attached_vmdk( vol_dev_uuid = mock.sentinel.vol_dev_uuid get_volume_device_uuid.return_value = vol_dev_uuid + dev_change_disk_remove = mock.sentinel.dev_change_disk_remove + vops._create_device_change_for_disk_removal.return_value =\ + [dev_change_disk_remove] + tmp_name = mock.sentinel.tmp_name generate_uuid.return_value = tmp_name @@ -2393,7 +2596,8 @@ def test_create_temp_backing_from_attached_vmdk( vops.clone_backing.assert_called_once_with( tmp_name, instance, None, volumeops.FULL_CLONE_TYPE, datastore, host=host, resource_pool=rp, folder=folder, - disks_to_clone=[vol_dev_uuid]) + device_changes=[dev_change_disk_remove], + extra_config={'nvp.vm-uuid': ''}) @mock.patch.object(VMDK_DRIVER, '_get_disk_type') @mock.patch.object(VMDK_DRIVER, 'volumeops') @@ -2765,7 +2969,11 @@ def _test_create_backing( ret = self._driver._create_backing(volume, host, create_params) self.assertEqual(backing, ret) - select_ds_for_volume.assert_called_once_with(volume, host) + select_ds_for_volume.assert_called_once_with( + volume, + host, + cinder_host=None, + create_params=create_params) get_storage_profile_id.assert_called_once_with(volume) exp_extra_config = {vmdk.EXTRA_CONFIG_VOLUME_ID_KEY: volume['id'], @@ -2942,6 +3150,33 @@ def test_relocate_backing_nop(self, ds_sel, get_profile, vops): profile) self.assertFalse(vops.relocate_backing.called) + @mock.patch.object(VMDK_DRIVER, 'volumeops') + @mock.patch.object(VMDK_DRIVER, '_get_storage_profile') + @mock.patch.object(VMDK_DRIVER, 'ds_sel') + def test_relocate_backing_nop_on_attach_if_disabled(self, ds_sel, + get_profile, vops): + self._driver._storage_policy_enabled = True + self._driver.configuration.vmware_profile_check_on_attach = False + volume = self._create_volume_dict() + + datastore = mock.sentinel.datastore + vops.get_datastore.return_value = datastore + + profile = mock.sentinel.profile + get_profile.return_value = profile + + vops.is_datastore_accessible.return_value = True + ds_sel.is_datastore_compliant.return_value = False + + backing = mock.sentinel.backing + host = mock.sentinel.host + self._driver._relocate_backing(volume, backing, host) + + get_profile.assert_called_once_with(volume) + vops.is_datastore_accessible.assert_called_once_with(datastore, host) + self.assertFalse(ds_sel.is_datastore_compliant.called) + self.assertFalse(vops.relocate_backing.called) + @mock.patch.object(VMDK_DRIVER, 'volumeops') @mock.patch.object(VMDK_DRIVER, '_get_storage_profile') @mock.patch.object(VMDK_DRIVER, 'ds_sel') @@ -3361,6 +3596,137 @@ def test_revert_to_snapshot(self, vops): vops.revert_to_snapshot.assert_called_once_with(backing, snapshot.name) + @mock.patch.object(VMDK_DRIVER, 'volumeops') + @mock.patch('oslo_vmware.vim_util.get_moref') + def test_migrate_volume(self, get_moref, vops, backing=None, + raises_error=False, capabilities=None): + r_api = mock.Mock() + self._driver._remote_api = r_api + volume = self._create_volume_obj() + vops.get_backing.return_value = backing + if capabilities is None: + capabilities = { + 'location_info': vmdk.LOCATION_DRIVER_NAME + ":foo_vcenter" + } + host = { + 'host': 'fake-host', + 'capabilities': capabilities + } + ds_info = {'host': 'fake-ds-host', 'resource_pool': 'fake-rp', + 'datastore': 'fake-ds-name', 'folder': 'fake-folder', + 'profile_id': 'fake-profile-id', + } + get_moref.side_effect = [ + mock.sentinel.host_ref, + mock.sentinel.rp_ref, + mock.sentinel.ds_ref + ] + r_api.get_service_locator_info.return_value = \ + mock.sentinel.service_locator + r_api.select_ds_for_volume.return_value = ds_info + if raises_error: + r_api.move_volume_backing_to_folder.side_effect = Exception + + ret_val = self._driver.migrate_volume(mock.sentinel.context, volume, + host) + + dest_host = host['host'] + + def _assertions_migration_not_called(): + r_api.get_service_locator_info.assert_not_called() + r_api.select_ds_for_volume.assert_not_called() + vops.relocate_backing.assert_not_called() + r_api.move_volume_backing_to_folder.assert_not_called() + get_moref.assert_not_called() + + def _assertions_for_no_backing(): + vops.get_backing.assert_called_once_with(volume.name, volume.id) + _assertions_migration_not_called() + self.assertEqual((True, None), ret_val) + + def _assertions_migration_not_performed(): + _assertions_migration_not_called() + self.assertEqual((False, None), ret_val) + + def _assertions_for_migration(): + vops.get_backing.assert_called_once_with(volume.name, volume.id) + r_api.get_service_locator_info.assert_called_once_with( + mock.sentinel.context, dest_host) + + r_api.select_ds_for_volume.assert_called_once_with( + mock.sentinel.context, volume=volume, cinder_host=dest_host) + + get_moref.assert_has_calls([ + mock.call(ds_info['host'], 'HostSystem'), + mock.call(ds_info['resource_pool'], 'ResourcePool'), + mock.call(ds_info['datastore'], 'Datastore')]) + + vops.relocate_backing.assert_called_once_with( + backing, mock.sentinel.ds_ref, mock.sentinel.rp_ref, + mock.sentinel.host_ref, profile_id='fake-profile-id', + service=mock.sentinel.service_locator) + + r_api.move_volume_backing_to_folder.assert_called_once_with( + mock.sentinel.context, dest_host, volume, ds_info['folder']) + + if raises_error: + self.assertEqual((True, {'migration_status': 'error'}), + ret_val) + else: + self.assertEqual((True, None), ret_val) + + if capabilities and 'location_info' in capabilities: + if capabilities['location_info'].startswith( + '%s:' % vmdk.LOCATION_DRIVER_NAME): + if backing: + _assertions_for_migration() + else: + _assertions_for_no_backing() + else: + _assertions_migration_not_performed() + else: + _assertions_migration_not_performed() + + def test_migrate_volume_relocate_existing_backing(self): + self.test_migrate_volume(backing=mock.Mock()) + + def test_migrate_volume_move_to_folder_error(self): + self.test_migrate_volume(backing=mock.Mock(), raises_error=True) + + def test_migrate_volume_missing_location_info(self): + self.test_migrate_volume(backing=mock.Mock(), capabilities={}) + + def test_migrate_volume_invalid_location_info(self): + self.test_migrate_volume(backing=mock.Mock(), capabilities={ + 'location_info': 'invalid-location-info' + }) + + @mock.patch.object(VMDK_DRIVER, 'volumeops') + def test_update_migrated_volume(self, vops): + volume = self._create_volume_obj() + new_volume = self._create_volume_obj(vol_id='new-id') + backing = mock.Mock() + vops.get_backing.return_value = backing + ret_val = self._driver.update_migrated_volume(self._context, volume, + new_volume, 'old-status') + vops.rename_backing.assert_called_once_with(backing, volume['name']) + vops.update_backing_uuid.assert_called_once_with(backing, volume['id']) + vops.update_backing_disk_uuid.assert_called_once_with(backing, + volume['id']) + self.assertIsNone(ret_val) + + @mock.patch.object(VMDK_DRIVER, 'volumeops') + def test_update_migrated_volume_without_backing(self, vops): + volume = self._create_volume_obj() + new_volume = self._create_volume_obj(vol_id='new-id') + vops.get_backing.return_value = None + ret_val = self._driver.update_migrated_volume(self._context, volume, + new_volume, 'old-status') + vops.rename_backing.assert_not_called() + vops.update_backing_uuid.assert_not_called() + vops.update_backing_disk_uuid.assert_not_called() + self.assertIsNone(ret_val) + @ddt.ddt class ImageDiskTypeTest(test.TestCase): diff --git a/cinder/tests/unit/volume/drivers/vmware/test_vmware_volumeops.py b/cinder/tests/unit/volume/drivers/vmware/test_vmware_volumeops.py index 4f1ab79485e..d9904173643 100644 --- a/cinder/tests/unit/volume/drivers/vmware/test_vmware_volumeops.py +++ b/cinder/tests/unit/volume/drivers/vmware/test_vmware_volumeops.py @@ -124,7 +124,7 @@ def _create_backing_obj(self, name, ref, instance_uuid=None, vol_id=None): backing.propSet = [name_prop, instance_uuid_prop, vol_id_prop] return backing - @mock.patch('cinder.volume.drivers.vmware.volumeops.VMwareVolumeOps.' + @mock.patch('oslo_vmware.vim_util.' 'continue_retrieval', return_value=None) def test_build_backing_ref_cache(self, continue_retrieval): uuid1 = 'd68cbee0-c1f7-4886-98a4-cf2201461c6e' @@ -162,7 +162,7 @@ def test_build_backing_ref_cache(self, continue_retrieval): 'name', 'config.instanceUuid', 'config.extraConfig["cinder.volume.id"]']) - continue_retrieval.assert_called_once_with(result) + continue_retrieval.assert_called_once_with(self.session.vim, result) def test_delete_backing(self): backing = mock.sentinel.backing @@ -192,8 +192,10 @@ def _host_runtime_info( inMaintenanceMode=in_maintenance) def test_get_hosts(self): - hosts = mock.sentinel.hosts - self.session.invoke_api.return_value = hosts + retrieve_results = mock.sentinel.retrieve_results + hosts = [mock.sentinel.hosts] + retrieve_results.objects = hosts + self.session.invoke_api.return_value = retrieve_results result = self.vops.get_hosts() self.assertEqual(hosts, result) self.session.invoke_api.assert_called_once_with(vim_util, @@ -202,26 +204,6 @@ def test_get_hosts(self): 'HostSystem', self.MAX_OBJECTS) - def test_continue_retrieval(self): - retrieve_result = mock.sentinel.retrieve_result - self.session.invoke_api.return_value = retrieve_result - result = self.vops.continue_retrieval(retrieve_result) - self.assertEqual(retrieve_result, result) - self.session.invoke_api.assert_called_once_with(vim_util, - 'continue_retrieval', - self.session.vim, - retrieve_result) - - def test_cancel_retrieval(self): - retrieve_result = mock.sentinel.retrieve_result - self.session.invoke_api.return_value = retrieve_result - result = self.vops.cancel_retrieval(retrieve_result) - self.assertIsNone(result) - self.session.invoke_api.assert_called_once_with(vim_util, - 'cancel_retrieval', - self.session.vim, - retrieve_result) - def test_is_usable(self): mount_info = mock.Mock(spec=object) mount_info.accessMode = "readWrite" @@ -558,7 +540,7 @@ def test_create_virtual_disk_config_spec(self): cf = self.session.vim.client.factory cf.create.side_effect = lambda *args: mock.Mock() - size_kb = units.Ki + size_kb = volumeops.MIN_VIRTUAL_DISK_SIZE_KB controller_key = 200 disk_type = 'thick' profile_id = mock.sentinel.profile_id @@ -596,7 +578,8 @@ def test_create_specs_for_ide_disk_add(self): factory.create.side_effect = None self.assertEqual(1, len(ret)) - self.assertEqual(units.Ki, ret[0].device.capacityInKB) + expected_size = volumeops.MIN_VIRTUAL_DISK_SIZE_KB + self.assertEqual(expected_size, ret[0].device.capacityInKB) self.assertEqual(200, ret[0].device.controllerKey) expected = [mock.call.create('ns0:VirtualDeviceConfigSpec'), mock.call.create('ns0:VirtualDisk'), @@ -607,7 +590,7 @@ def test_create_specs_for_scsi_disk_add(self): factory = self.session.vim.client.factory factory.create.side_effect = lambda *args: mock.Mock() - size_kb = 2 * units.Ki + size_kb = 8 * units.Ki disk_type = 'thin' adapter_type = 'lsiLogicsas' profile_id = mock.sentinel.profile_id @@ -851,7 +834,9 @@ def test_relocate_backing(self, get_relocate_spec, get_disk_device): get_disk_device.assert_called_once_with(backing) get_relocate_spec.assert_called_once_with(datastore, resource_pool, host, disk_move_type, - disk_type, disk_device) + disk_type, disk_device, + profile_id=None, + service=None) self.session.invoke_api.assert_called_once_with(self.session.vim, 'RelocateVM_Task', backing, @@ -1034,10 +1019,8 @@ def _verify_extra_config(self, option_values, key, value): '_get_relocate_spec') @mock.patch('cinder.volume.drivers.vmware.volumeops.VMwareVolumeOps.' '_get_disk_device') - @mock.patch('cinder.volume.drivers.vmware.volumeops.VMwareVolumeOps.' - '_create_device_change_for_disk_removal') def _test_get_clone_spec( - self, create_device_change_for_disk_removal, get_disk_device, + self, get_disk_device, get_relocate_spec, disk_type=None): factory = self.session.vim.client.factory factory.create.side_effect = lambda *args: mock.Mock() @@ -1050,9 +1033,6 @@ def _test_get_clone_spec( else: disk_device = None - dev_change = mock.sentinel.dev_change - create_device_change_for_disk_removal.return_value = dev_change - datastore = mock.sentinel.datastore disk_move_type = mock.sentinel.disk_move_type snapshot = mock.sentinel.snapshot @@ -1063,7 +1043,6 @@ def _test_get_clone_spec( value = mock.sentinel.value extra_config = {key: value, volumeops.BACKING_UUID_KEY: mock.sentinel.uuid} - disks_to_clone = [mock.sentinel.disk_uuid] ret = self.vops._get_clone_spec(datastore, disk_move_type, snapshot, @@ -1072,7 +1051,7 @@ def _test_get_clone_spec( host=host, resource_pool=rp, extra_config=extra_config, - disks_to_clone=disks_to_clone) + device_changes='fake-device-changes') self.assertEqual(relocate_spec, ret.location) self.assertFalse(ret.powerOn) @@ -1087,9 +1066,7 @@ def _test_get_clone_spec( disk_move_type, disk_type, disk_device) self._verify_extra_config(ret.config.extraConfig, key, value) - create_device_change_for_disk_removal.assert_called_once_with( - backing, disks_to_clone) - self.assertEqual(dev_change, ret.config.deviceChange) + self.assertEqual('fake-device-changes', ret.config.deviceChange) def test_get_clone_spec(self): self._test_get_clone_spec() @@ -1100,9 +1077,9 @@ def test_get_clone_spec_with_thin_disk_type(self): @mock.patch('cinder.volume.drivers.vmware.volumeops.VMwareVolumeOps.' '_get_disk_devices') @mock.patch('cinder.volume.drivers.vmware.volumeops.VMwareVolumeOps.' - '_create_spec_for_disk_remove') + '_create_spec_for_device_remove') def test_create_device_change_for_disk_removal( - self, create_spec_for_disk_remove, get_disk_devices): + self, create_spec_for_device_remove, get_disk_devices): uuid_1 = mock.sentinel.uuid_1 disk_dev_1 = self._create_disk_device('foo', uuid_1) @@ -1112,7 +1089,7 @@ def test_create_device_change_for_disk_removal( get_disk_devices.return_value = [disk_dev_1, disk_dev_2] spec = mock.sentinel.spec - create_spec_for_disk_remove.return_value = spec + create_spec_for_device_remove.return_value = spec backing = mock.sentinel.backing disks_to_clone = [uuid_2] @@ -1120,9 +1097,35 @@ def test_create_device_change_for_disk_removal( backing, disks_to_clone) get_disk_devices.assert_called_once_with(backing) - create_spec_for_disk_remove.assert_called_once_with(disk_dev_1) + create_spec_for_device_remove.assert_called_once_with(disk_dev_1) self.assertEqual([spec], ret) + @mock.patch('cinder.volume.drivers.vmware.volumeops.VMwareVolumeOps.' + '_get_vif_devices') + @mock.patch('cinder.volume.drivers.vmware.volumeops.VMwareVolumeOps.' + '_create_spec_for_device_remove') + def test_create_device_change_for_vif_removal( + self, create_spec_for_device_remove, get_vif_devices): + mac1 = mock.sentinel.mac1 + vif_dev_1 = self._create_vif_device(mac1) + + mac2 = mock.sentinel.mac2 + vif_dev_2 = self._create_vif_device(mac2) + + get_vif_devices.return_value = [vif_dev_1, vif_dev_2] + + spec = mock.sentinel.spec + create_spec_for_device_remove.return_value = spec + + backing = mock.sentinel.backing + ret = self.vops._create_device_change_for_vif_removal(backing) + + get_vif_devices.assert_called_once_with(backing) + exp_calls = [mock.call(vif_dev_1), mock.call(vif_dev_2)] + self.assertEqual(exp_calls, + create_spec_for_device_remove.call_args_list) + self.assertEqual([spec, spec], ret) + @mock.patch('cinder.volume.drivers.vmware.volumeops.VMwareVolumeOps.' '_get_folder') @mock.patch('cinder.volume.drivers.vmware.volumeops.VMwareVolumeOps.' @@ -1166,7 +1169,7 @@ def _test_clone_backing( get_clone_spec.assert_called_once_with( datastore, exp_disk_move_type, snapshot, backing, disk_type, host=host, resource_pool=resource_pool, extra_config=extra_config, - disks_to_clone=None) + device_changes=None) exp_folder = folder if folder else backing_folder self.session.invoke_api.assert_called_once_with( @@ -1274,12 +1277,12 @@ def test_attach_disk_to_backing_existing_controller( spec=reconfig_spec) self.session.wait_for_task.assert_called_once_with(task) - def test_create_spec_for_disk_remove(self): + def test_create_spec_for_device_remove(self): disk_spec = mock.Mock() self.session.vim.client.factory.create.return_value = disk_spec disk_device = mock.sentinel.disk_device - self.vops._create_spec_for_disk_remove(disk_device) + self.vops._create_spec_for_device_remove(disk_device) self.session.vim.client.factory.create.assert_called_once_with( 'ns0:VirtualDeviceConfigSpec') @@ -1287,7 +1290,7 @@ def test_create_spec_for_disk_remove(self): self.assertEqual(disk_device, disk_spec.device) @mock.patch('cinder.volume.drivers.vmware.volumeops.VMwareVolumeOps.' - '_create_spec_for_disk_remove') + '_create_spec_for_device_remove') @mock.patch('cinder.volume.drivers.vmware.volumeops.VMwareVolumeOps.' '_reconfigure_backing') def test_detach_disk_from_backing(self, reconfigure_backing, create_spec): @@ -1797,7 +1800,7 @@ def test_get_cluster_hosts_with_no_host(self): cluster, 'host') - @mock.patch('cinder.volume.drivers.vmware.volumeops.VMwareVolumeOps.' + @mock.patch('oslo_vmware.vim_util.' 'continue_retrieval', return_value=None) def test_get_all_clusters(self, continue_retrieval): prop_1 = mock.Mock(val='test_cluster_1') @@ -1815,7 +1818,8 @@ def test_get_all_clusters(self, continue_retrieval): self.session.invoke_api.assert_called_once_with( vim_util, 'get_objects', self.session.vim, 'ClusterComputeResource', self.MAX_OBJECTS) - continue_retrieval.assert_called_once_with(retrieve_result) + continue_retrieval.assert_called_once_with(self.session.vim, + retrieve_result) def test_get_entity_by_inventory_path(self): self.session.invoke_api.return_value = mock.sentinel.ref @@ -1881,6 +1885,31 @@ def test_get_disk_device(self, get_disk_devices): self.vops.get_disk_device(vm, '[ds1] foo/foo_1.vmdk')) get_disk_devices.assert_called_once_with(vm) + def _create_vif_device(self, mac): + return mock.Mock(macAddress=mac) + + def test_get_vif_devices(self): + disk_device = mock.Mock(spec=[]) + disk_device.__class__.__name__ = 'VirtualDisk' + + controller_device = mock.Mock(spec=[]) + controller_device.__class__.__name__ = 'VirtualLSILogicController' + + vif_device = mock.Mock(spec=['macAddress']) + vif_device.__class__.__name__ = 'VirtualVmxnet3' + vif_device.macAddress = 'fake-mac' + + devices = mock.Mock() + devices.__class__.__name__ = "ArrayOfVirtualDevice" + devices.VirtualDevice = [disk_device, controller_device, vif_device] + self.session.invoke_api.return_value = devices + + vm = mock.sentinel.vm + self.assertEqual([vif_device], self.vops._get_vif_devices(vm)) + self.session.invoke_api.assert_called_once_with( + vim_util, 'get_object_property', self.session.vim, + vm, 'config.hardware.device') + @mock.patch('cinder.volume.drivers.vmware.volumeops.VMwareVolumeOps.' 'get_entity_by_inventory_path') def test_copy_datastore_file(self, get_entity_by_inventory_path): @@ -1935,36 +1964,63 @@ def test_create_fcd(self, create_profile_spec, create_fcd_backing_spec): profile_spec = mock.sentinel.profile_spec create_profile_spec.return_value = profile_spec - task = mock.sentinel.task - self.session.invoke_api.return_value = task - task_info = mock.Mock() fcd_id = mock.sentinel.fcd_id task_info.result.config.id.id = fcd_id self.session.wait_for_task.return_value = task_info - name = mock.sentinel.name + name = "volume-405d6afd-43be-4ce0-9e5f-fd49559e2763" size_mb = 1024 ds_ref_val = mock.sentinel.ds_ref_val + ds_ref_val.name = 'ds-1' + dc = mock.Mock(spec=object) + dc._type = 'Datacenter' ds_ref = mock.Mock(value=ds_ref_val) + ds_ref._type = 'Datastore' + ds_ref.parent = dc + ds_ref.name = 'ds-1' + cinder_uuid = '405d6afd-43be-4ce0-9e5f-fd49559e2763' disk_type = mock.sentinel.disk_type profile_id = mock.sentinel.profile_id - ret = self.vops.create_fcd( - name, size_mb, ds_ref, disk_type, profile_id=profile_id) + def mock_invoke_api(vim_util, method, vim, + the_object=None, arg=None, + name=None, datacenter=None, + spec=None, **kwargs): + if arg == "parent": + return the_object.parent + if arg == "name": + return the_object.name + if method == "CreateDisk_Task": + return task + if method == "MakeDirectory": + return None + self.session.invoke_api.side_effect = mock_invoke_api + ret = self.vops.create_fcd(cinder_uuid, name, size_mb, + ds_ref, disk_type, + profile_id=profile_id) self.assertEqual(fcd_id, ret.fcd_id) self.assertEqual(ds_ref_val, ret.ds_ref_val) self.session.vim.client.factory.create.assert_called_once_with( 'ns0:VslmCreateSpec') - create_fcd_backing_spec.assert_called_once_with(disk_type, ds_ref) + create_fcd_backing_spec.assert_called_once_with(disk_type, + ds_ref, + name) self.assertEqual(1024, spec.capacityInMB) self.assertEqual(name, spec.name) self.assertEqual(backing_spec, spec.backingSpec) self.assertEqual([profile_spec], spec.profile) create_profile_spec.assert_called_once_with( self.session.vim.client.factory, profile_id) - self.session.invoke_api.assert_called_once_with( + ds_folder_path = "[ds-1] %s" % name + self.session.invoke_api.assert_any_call( + self.session.vim, + 'MakeDirectory', + self.session.vim.service_content.fileManager, + name=ds_folder_path, + datacenter=dc) + self.session.invoke_api.assert_any_call( self.session.vim, 'CreateDisk_Task', self.session.vim.service_content.vStorageObjectManager, @@ -2003,10 +2059,20 @@ def test_clone_fcd(self, create_profile_spec, create_fcd_backing_spec): profile_spec = mock.sentinel.profile_spec create_profile_spec.return_value = profile_spec - task = mock.sentinel.task - self.session.invoke_api.return_value = task + def mock_invoke_api(vim_util, method, vim, + the_object=None, arg=None, + name=None, datacenter=None, + spec=None, **kwargs): + + if arg == "parent": + return dc + if arg == "name": + return the_object.name + if method == "CloneVStorageObject_Task": + return task + self.session.invoke_api.side_effect = mock_invoke_api task_info = mock.Mock() fcd_id = mock.sentinel.fcd_id task_info.result.config.id.id = fcd_id @@ -2015,28 +2081,39 @@ def test_clone_fcd(self, create_profile_spec, create_fcd_backing_spec): fcd_location = mock.Mock() fcd_id = mock.sentinel.fcd_id fcd_location.id.return_value = fcd_id - ds_ref = mock.sentinel.ds_ref - fcd_location.ds_ref.return_value = ds_ref + ds_ref_val = mock.sentinel.ds_ref_val + ds_ref_val.name = 'ds-1' + dc = mock.Mock(spec=object) + dc._type = 'Datacenter' + ds_ref = mock.Mock(value=ds_ref_val) + ds_ref._type = 'Datastore' + ds_ref.parent = dc + ds_ref.name = 'ds-1' - name = mock.sentinel.name + fcd_location.ds_ref.return_value = ds_ref + volume = mock.sentinel.name + volume.id = "9b3f6f1b-03a9-4f1e-aaff-ae15122b6ccf" + volume.name = "volume-9b3f6f1b-03a9-4f1e-aaff-ae15122b6ccf" dest_ds_ref_val = mock.sentinel.dest_ds_ref_val dest_ds_ref = mock.Mock(value=dest_ds_ref_val) disk_type = mock.sentinel.disk_type profile_id = mock.sentinel.profile_id - ret = self.vops.clone_fcd( - name, fcd_location, dest_ds_ref, disk_type, profile_id=profile_id) + ret = self.vops.clone_fcd(volume, fcd_location, dest_ds_ref, + disk_type, profile_id=profile_id) self.assertEqual(fcd_id, ret.fcd_id) self.assertEqual(dest_ds_ref_val, ret.ds_ref_val) self.session.vim.client.factory.create.assert_called_once_with( 'ns0:VslmCloneSpec') - create_fcd_backing_spec.assert_called_once_with(disk_type, dest_ds_ref) - self.assertEqual(name, spec.name) + create_fcd_backing_spec.assert_called_once_with(disk_type, + dest_ds_ref, + volume.name) + self.assertEqual(volume.name, spec.name) self.assertEqual(backing_spec, spec.backingSpec) self.assertEqual([profile_spec], spec.profile) create_profile_spec.assert_called_once_with( self.session.vim.client.factory, profile_id) - self.session.invoke_api.assert_called_once_with( + self.session.invoke_api.assert_any_call( self.session.vim, 'CloneVStorageObject_Task', self.session.vim.service_content.vStorageObjectManager, @@ -2159,7 +2236,7 @@ def test_create_fcd_snapshot(self): self.assertEqual(fcd_snap_id, ret.snap_id) self.assertEqual(fcd_location, ret.fcd_loc) - self.session.invoke_api.assert_called_once_with( + self.session.invoke_api.assert_any_call( self.session.vim, 'VStorageObjectCreateSnapshot_Task', self.session.vim.service_content.vStorageObjectManager, @@ -2195,7 +2272,6 @@ def test_delete_fcd_snapshot(self): '_create_profile_spec') def test_create_fcd_from_snapshot(self, create_profile_spec): task = mock.sentinel.task - self.session.invoke_api.return_value = task profile_spec = mock.sentinel.profile_spec create_profile_spec.return_value = profile_spec @@ -2205,26 +2281,48 @@ def test_create_fcd_from_snapshot(self, create_profile_spec): task_info.result.config.id.id = fcd_id self.session.wait_for_task.return_value = task_info + def mock_invoke_api(vim_util, method, vim, + the_object=None, arg=None, + name=None, datacenter=None, + spec=None, **kwargs): + if arg == "parent": + return the_object.parent + if arg == "name": + return the_object.name + if method == "CreateDiskFromSnapshot_Task": + return task + if method == "MakeDirectory": + return None + self.session.invoke_api.side_effect = mock_invoke_api fcd_location = mock.Mock() fcd_id = mock.sentinel.fcd_id + dc = mock.Mock(spec=object) + dc._type = 'Datacenter' fcd_location.id.return_value = fcd_id ds_ref_val = mock.sentinel.ds_ref_val + ds_ref_val.name = 'ds-1' + ds_ref_val.parent = dc + ds_ref_val._type = 'Datastore' ds_ref = mock.Mock(value=ds_ref_val) + ds_ref._type = 'Datastore' + ds_ref.parent = dc + ds_ref.name = 'ds-1' fcd_location.ds_ref.return_value = ds_ref fcd_snap_id = mock.sentinel.fcd_snap_id fcd_snap_loc = mock.Mock(fcd_loc=fcd_location) fcd_snap_loc.id.return_value = fcd_snap_id - name = mock.sentinel.name + name = "volume-9b3f6f1b-03a9-4f1e-aaff-ae15122b6ccf" + cinder_uuid = "9b3f6f1b-03a9-4f1e-aaff-ae15122b6ccf" profile_id = mock.sentinel.profile_id ret = self.vops.create_fcd_from_snapshot( - fcd_snap_loc, name, profile_id=profile_id) + fcd_snap_loc, name, cinder_uuid, profile_id=profile_id) self.assertEqual(fcd_id, ret.fcd_id) self.assertEqual(ds_ref_val, ret.ds_ref_val) create_profile_spec.assert_called_once_with( self.session.vim.client.factory, profile_id) - self.session.invoke_api.assert_called_once_with( + self.session.invoke_api.assert_any_call( self.session.vim, 'CreateDiskFromSnapshot_Task', self.session.vim.service_content.vStorageObjectManager, @@ -2232,7 +2330,8 @@ def test_create_fcd_from_snapshot(self, create_profile_spec): datastore=ds_ref, snapshotId=fcd_snap_id, name=name, - profile=[profile_spec]) + profile=[profile_spec], + path=name + '/') self.session.wait_for_task.assert_called_once_with(task) @mock.patch('cinder.volume.drivers.vmware.volumeops.VMwareVolumeOps.' diff --git a/cinder/tests/unit/volume/test_capabilities.py b/cinder/tests/unit/volume/test_capabilities.py index 773ea290972..9c68ed41057 100644 --- a/cinder/tests/unit/volume/test_capabilities.py +++ b/cinder/tests/unit/volume/test_capabilities.py @@ -199,9 +199,9 @@ def test_extra_capabilities(self): self.assertTrue(mock_loads.called) volume_stats = manager.last_capabilities self.assertEqual(fake_capabilities['key1'], - volume_stats['key1']) + volume_stats["pools"][0]['key1']) self.assertEqual(fake_capabilities['key2'], - volume_stats['key2']) + volume_stats["pools"][0]['key2']) def test_extra_capabilities_fail(self): with mock.patch.object(jsonutils, 'loads') as mock_loads: diff --git a/cinder/tests/unit/volume/test_connection.py b/cinder/tests/unit/volume/test_connection.py index 0d472d6228b..abb5908fc14 100644 --- a/cinder/tests/unit/volume/test_connection.py +++ b/cinder/tests/unit/volume/test_connection.py @@ -500,8 +500,9 @@ def test_run_attach_detach_volume_for_instance(self, volume_object): self.context, volume_id) - @mock.patch('cinder.volume.manager.LOG', mock.Mock()) - def test_initialize_connection(self): + @mock.patch.object(cinder.action_track.LOG, "log") + @mock.patch.object(cinder.volume.manager.LOG, "log") + def test_initialize_connection(self, _mock_manager, _mock_track): volume = mock.Mock(save=mock.Mock(side_effect=Exception)) with mock.patch.object(self.volume, 'driver') as driver_mock: self.assertRaises(exception.ExportFailure, diff --git a/cinder/tests/unit/volume/test_manage_volume.py b/cinder/tests/unit/volume/test_manage_volume.py index 5d4cc73f1ae..0a0eab66deb 100644 --- a/cinder/tests/unit/volume/test_manage_volume.py +++ b/cinder/tests/unit/volume/test_manage_volume.py @@ -58,7 +58,7 @@ def test_manage_existing(self): self.manager, '_run_manage_existing_flow_engine', return_value=volume_object) mock_update_volume_stats = self.mock_object( - self.manager, '_update_stats_for_managed') + self.manager, '_update_allocated_capacity') result = self.manager.manage_existing(self.context, volume_object) @@ -75,7 +75,7 @@ def test_manage_existing_with_volume_object(self): self.manager, '_run_manage_existing_flow_engine', return_value=volume_object) mock_update_volume_stats = self.mock_object( - self.manager, '_update_stats_for_managed') + self.manager, '_update_allocated_capacity') result = self.manager.manage_existing( self.context, volume_object) @@ -134,7 +134,7 @@ def test_update_stats_for_managed(self): volume_object = self._stub_volume_object_get(self, host=FAKE_HOST + '#volPool') - self.manager._update_stats_for_managed(volume_object) + self.manager._update_allocated_capacity(volume_object) backend_stats = self.manager.stats['pools'][FAKE_HOST_POOL] self.assertEqual( 1, backend_stats['allocated_capacity_gb']) @@ -146,7 +146,7 @@ def test_update_stats_for_managed_no_pool(self): self.manager.driver.configuration, 'safe_get', return_value=safe_get_backend) - self.manager._update_stats_for_managed(volume_obj) + self.manager._update_allocated_capacity(volume_obj) mock_safe_get.assert_called_once_with('volume_backend_name') backend_stats = self.manager.stats['pools'][safe_get_backend] @@ -157,7 +157,7 @@ def test_update_stats_for_managed_default_backend(self): mock_safe_get = self.mock_object( self.manager.driver.configuration, 'safe_get', return_value=None) - self.manager._update_stats_for_managed(volume_obj) + self.manager._update_allocated_capacity(volume_obj) mock_safe_get.assert_called_once_with('volume_backend_name') pool_stats = self.manager.stats['pools'] @@ -168,7 +168,7 @@ def test_update_stats_key_error(self): self.manager.stats = {} self.assertRaises( - KeyError, self.manager._update_stats_for_managed, + KeyError, self.manager._update_allocated_capacity, self._stub_volume_object_get(self)) @mock.patch('cinder.volume.drivers.lvm.LVMVolumeDriver.' diff --git a/cinder/tests/unit/volume/test_rpcapi.py b/cinder/tests/unit/volume/test_rpcapi.py index fcbcb7878ba..4022feaa340 100644 --- a/cinder/tests/unit/volume/test_rpcapi.py +++ b/cinder/tests/unit/volume/test_rpcapi.py @@ -303,6 +303,7 @@ def __init__(self): volume=self.fake_volume_obj, dest_backend=dest_backend, force_host_copy=True, + extend_spec=None, expected_kwargs_diff={ 'host': {'host': 'fake_host', 'cluster_name': 'cluster_name', @@ -413,7 +414,24 @@ def test_remove_export(self): 'volume_id': self.fake_volume_obj.id}) @ddt.data(None, 'mycluster') - def test_get_backup_device(self, cluster_name): + def test_get_backup_device_cast(self, cluster_name): + self._change_cluster_name(self.fake_volume_obj, cluster_name) + self._test_rpc_api('get_backup_device', + rpc_method='cast', + server=cluster_name or self.fake_volume_obj.host, + backup=self.fake_backup_obj, + volume=self.fake_volume_obj, + expected_kwargs_diff={ + 'want_objects': True, + 'async_call': True, + }, + retval=None, + version='3.17') + + @ddt.data(None, 'mycluster') + def test_get_backup_device_call(self, cluster_name): + self.can_send_version_mock.side_effect = (False, False, True, False, + True) self._change_cluster_name(self.fake_volume_obj, cluster_name) backup_device_dict = {'backup_device': self.fake_volume, 'is_snapshot': False, @@ -433,7 +451,8 @@ def test_get_backup_device(self, cluster_name): @ddt.data(None, 'mycluster') def test_get_backup_device_old(self, cluster_name): - self.can_send_version_mock.side_effect = (True, False, False) + self.can_send_version_mock.side_effect = (False, False, False, False, + False) self._change_cluster_name(self.fake_volume_obj, cluster_name) backup_device_dict = {'backup_device': self.fake_volume, 'is_snapshot': False, diff --git a/cinder/tests/unit/volume/test_volume.py b/cinder/tests/unit/volume/test_volume.py index 9f4c8150200..e97546bad2f 100644 --- a/cinder/tests/unit/volume/test_volume.py +++ b/cinder/tests/unit/volume/test_volume.py @@ -1460,7 +1460,8 @@ def mock_flow_run(*args, **kwargs): # locked self.volume.delete_volume(self.context, dst_vol) - mock_lock.assert_called_with('%s-delete_volume' % dst_vol.id) + mock_lock.assert_any_call('%s-delete_volume' % dst_vol.id) + mock_lock.assert_any_call('volume-stats-%s' % self.volume.host) # locked self.volume.delete_snapshot(self.context, snapshot_obj) @@ -1468,7 +1469,8 @@ def mock_flow_run(*args, **kwargs): # locked self.volume.delete_volume(self.context, src_vol) - mock_lock.assert_called_with('%s-delete_volume' % src_vol.id) + mock_lock.assert_any_call('%s-delete_volume' % src_vol.id) + mock_lock.assert_any_call('volume-stats-%s' % self.volume.host) self.assertTrue(mock_lvm_create.called) @@ -1513,11 +1515,12 @@ def mock_flow_run(*args, **kwargs): # locked self.volume.delete_volume(self.context, dst_vol) - mock_lock.assert_called_with('%s-delete_volume' % dst_vol_id) + mock_lock.assert_any_call('%s-delete_volume' % dst_vol_id) + mock_lock.assert_any_call('volume-stats-%s' % self.volume.host) # locked self.volume.delete_volume(self.context, src_vol) - mock_lock.assert_called_with('%s-delete_volume' % src_vol_id) + mock_lock.assert_any_call('%s-delete_volume' % src_vol_id) def _raise_metadata_copy_failure(self, method, dst_vol): # MetadataCopyFailure exception will be raised if DB service is Down diff --git a/cinder/tests/unit/volume/test_volume_migration.py b/cinder/tests/unit/volume/test_volume_migration.py index cd3136b6506..64c731cad8d 100644 --- a/cinder/tests/unit/volume/test_volume_migration.py +++ b/cinder/tests/unit/volume/test_volume_migration.py @@ -86,7 +86,7 @@ def tearDown(self): super(VolumeMigrationTestCase, self).tearDown() self._clear_patch.stop() - def test_migrate_volume_driver(self): + def test_migrate_volume_driver(self, extend_spec=None): """Test volume migration done by driver.""" # Mock driver and rpc functions self.mock_object(self.volume.driver, 'migrate_volume', @@ -97,7 +97,8 @@ def test_migrate_volume_driver(self): host=CONF.host, migration_status='migrating') host_obj = {'host': 'newhost', 'capabilities': {}} - self.volume.migrate_volume(self.context, volume, host_obj, False) + self.volume.migrate_volume(self.context, volume, host_obj, False, + extend_spec=extend_spec) # check volume properties volume = objects.Volume.get_by_id(context.get_admin_context(), @@ -151,6 +152,15 @@ def test_migrate_volume_driver_for_retype_generic(self, mock_can_use, mock_generic.assert_called_once_with(self.context, volume, host_obj, fake.VOLUME_TYPE2_ID) + @mock.patch.object(volume_rpcapi.VolumeAPI, 'extend_volume') + def test_migrate_volume_driver_with_extend(self, fake_extend): + extend_spec = {'new_size': 10, 'reservations': 'fake-rsv'} + self.test_migrate_volume_driver(extend_spec=extend_spec) + self.assertTrue(fake_extend.called) + named_args = fake_extend.call_args[1] + self.assertEqual(10, named_args['new_size']) + self.assertEqual('fake-rsv', named_args['reservations']) + def test_migrate_volume_driver_cross_az(self): """Test volume migration done by driver.""" # Mock driver and rpc functions diff --git a/cinder/utils.py b/cinder/utils.py index 751fe077cdc..c36c5dfa5ce 100644 --- a/cinder/utils.py +++ b/cinder/utils.py @@ -751,7 +751,7 @@ def _limit(x): max_over_subscription_ratio if provisioned_type == 'thin' else None ), "total_available_capacity": _limit(total_available_capacity), - "provisioned_capacity": provisioned_capacity, + "provisioned_capacity": _limit(provisioned_capacity), "calculated_free_capacity": _limit(calculated_free), "virtual_free_capacity": _limit(virtual_free), "free_percent": _limit(free_percent), diff --git a/cinder/volume/api.py b/cinder/volume/api.py index c7ac1af1d4c..2fc8aa18354 100644 --- a/cinder/volume/api.py +++ b/cinder/volume/api.py @@ -26,8 +26,10 @@ from oslo_utils import excutils from oslo_utils import strutils from oslo_utils import timeutils +from oslo_utils import uuidutils from oslo_utils import versionutils +from cinder import action_track from cinder.api import common from cinder.common import constants from cinder import context @@ -54,6 +56,7 @@ from cinder.policies import volumes as vol_policy from cinder import quota from cinder import quota_utils +from cinder.scheduler import host_manager from cinder.scheduler import rpcapi as scheduler_rpcapi from cinder import utils from cinder.volume.flows.api import create_volume @@ -88,7 +91,6 @@ CONF.register_opt(volume_host_opt) CONF.register_opt(volume_same_az_opt) CONF.register_opt(az_cache_time_opt) - CONF.import_opt('glance_core_properties', 'cinder.image.glance') LOG = logging.getLogger(__name__) @@ -203,6 +205,37 @@ def _is_encrypted(self, volume_type): return False return specs.get('encryption', {}) is not {} + def _validate_scheduler_hints(self, scheduler_hints): + if scheduler_hints: + validate_volume_uuids = [] + if 'same_host' in scheduler_hints: + if isinstance(scheduler_hints['same_host'], list): + validate_volume_uuids.extend(scheduler_hints['same_host']) + else: + validate_volume_uuids.append(scheduler_hints['same_host']) + elif 'different_host' in scheduler_hints: + if isinstance(scheduler_hints['different_host'], list): + validate_volume_uuids.extend( + scheduler_hints['different_host']) + else: + validate_volume_uuids.append( + scheduler_hints['different_host']) + + for hint_volume_id in validate_volume_uuids: + if not uuidutils.is_uuid_like(hint_volume_id): + msg = _("Invalid UUID(s) '%s' provided in scheduler " + "hints.") % hint_volume_id + raise exception.InvalidInput(reason=msg) + + # Now validate that the uuids are valid volumes that exist in + # cinder DB. + for hint_volume_id in validate_volume_uuids: + # All we have to do here is try and fetch the UUID as volume. + # If the UUID doesn't exist in the DB, Cinder will throw + # a VolumeNotFound exception. + objects.Volume.get_by_id(context, hint_volume_id) + + @action_track.track_decorator(action_track.ACTION_VOLUME_CREATE) def create(self, context, size, name, description, snapshot=None, image_id=None, volume_type=None, metadata=None, availability_zone=None, source_volume=None, @@ -296,6 +329,19 @@ def create(self, context, size, name, description, snapshot=None, if CONF.storage_availability_zone: availability_zones.add(CONF.storage_availability_zone) + # Validate the scheduler_hints same_host and different_hosts as + # valid volume UUIDs. + self._validate_scheduler_hints(scheduler_hints) + + # Force the scheduler hints into the volume metadata + if not metadata: + metadata = {} + + metadata = volume_utils.set_scheduler_hints_to_volume_metadata( + scheduler_hints, + metadata + ) + utils.check_metadata_properties(metadata) create_what = { @@ -335,6 +381,10 @@ def create(self, context, size, name, description, snapshot=None, create_what, sched_rpcapi, volume_rpcapi) + action_track.track( + context, action_track.ACTION_VOLUME_CREATE, + None, "taskflow get_flow() created" + ) except Exception: msg = _('Failed to create api volume flow.') LOG.exception(msg) @@ -352,8 +402,10 @@ def create(self, context, size, name, description, snapshot=None, if flow_engine.storage.fetch('refresh_az'): self.list_availability_zones(enable_cache=True, refresh_cache=True) - LOG.info("Create volume request issued successfully.", - resource=vref) + action_track.track( + context, action_track.ACTION_VOLUME_CREATE, + vref, "create volume request issued successfully" + ) return vref except exception.InvalidAvailabilityZone: with excutils.save_and_reraise_exception(): @@ -687,6 +739,7 @@ def get_all_snapshots(self, context, search_opts=None, marker=None, LOG.info("Get all snapshots completed successfully.") return snapshots + @action_track.track_decorator(action_track.ACTION_VOLUME_RESERVE) def reserve_volume(self, context, volume): context.authorize(vol_action_policy.RESERVE_POLICY, target_obj=volume) expected = {'multiattach': volume.multiattach, @@ -700,11 +753,12 @@ def reserve_volume(self, context, volume): msg = _('Volume status must be %(expected)s to reserve, but the ' 'status is %(current)s.') % {'expected': expected_status, 'current': volume.status} - LOG.error(msg) raise exception.InvalidVolume(reason=msg) - LOG.info("Reserve volume completed successfully.", - resource=volume) + action_track.track( + context, action_track.ACTION_VOLUME_ATTACH, + volume, "Reserve volume completed successfully." + ) def unreserve_volume(self, context, volume): context.authorize(vol_action_policy.UNRESERVE_POLICY, @@ -754,6 +808,7 @@ def roll_detaching(self, context, volume): LOG.info("Roll detaching of volume completed successfully.", resource=volume) + @action_track.track_decorator(action_track.ACTION_VOLUME_ATTACH) def attach(self, context, volume, instance_uuid, host_name, mountpoint, mode): context.authorize(vol_action_policy.ATTACH_POLICY, @@ -773,16 +828,23 @@ def attach(self, context, volume, instance_uuid, host_name, raise exception.InvalidVolumeAttachMode(mode=mode, volume_id=volume.id) + action_track.track( + context, action_track.ACTION_VOLUME_ATTACH, + volume, "calling volume_rpc_api.attach_volume" + ) attach_results = self.volume_rpcapi.attach_volume(context, volume, instance_uuid, host_name, mountpoint, mode) - LOG.info("Attach volume completed successfully.", - resource=volume) + action_track.track( + context, action_track.ACTION_VOLUME_ATTACH, + volume, "Attach volume completed successfully." + ) return attach_results + @action_track.track_decorator(action_track.ACTION_VOLUME_DETACH) def detach(self, context, volume, attachment_id): context.authorize(vol_action_policy.DETACH_POLICY, target_obj=volume) @@ -797,6 +859,92 @@ def detach(self, context, volume, attachment_id): resource=volume) return detach_results + @action_track.track_decorator(action_track.ACTION_VOLUME_MIGRATE) + def migrate_volume_by_connector(self, ctxt, volume, connector, + lock_volume): + if not connector: + raise exception.InvalidInput("Must provide a valid Connector") + + volume_type = {} + if volume.volume_type_id: + volume_type = volume_types.get_volume_type( + ctxt.elevated(), volume.volume_type_id) + request_spec = { + 'volume_properties': volume, + 'volume_type': volume_type, + 'volume_id': volume.id} + + # Check if there is an affinity/antiaffinity against the volume + filter_properties = \ + volume_utils.get_scheduler_hints_from_volume(volume) + + try: + dest = self.scheduler_rpcapi.find_backend_for_connector( + ctxt, connector, request_spec, volume.size, + filter_properties=filter_properties) + except exception.NoValidBackend: + msg = ("The connector was rejected by the backend. Could not " + "find another backend compatible with the connector %s." + % connector) + action_track.track(ctxt, action_track.ACTION_VOLUME_MIGRATE, + volume, msg, loglevel=logging.ERROR) + return None + backend = host_manager.BackendState(host=dest['host'], + cluster_name=dest['cluster_name'], + capabilities=dest['capabilities']) + + states = ('available', 'reserved', 'in-use') + # Build required conditions for conditional update + expected = {'status': states, + 'migration_status': self.AVAILABLE_MIGRATION_STATUS, + 'replication_status': ( + None, + fields.ReplicationStatus.DISABLED, + fields.ReplicationStatus.NOT_CAPABLE), + 'consistencygroup_id': (None, ''), + 'group_id': (None, '')} + + expected['host'] = db.Not(dest['host']) + filters = [~db.volume_has_snapshots_filter()] + + updates = {'migration_status': 'starting', + 'previous_status': volume.model.status} + + # When the migration of an available volume starts, both the status + # and the migration status of the volume will be changed. + # If the admin sets lock_volume flag to True, the volume + # status is changed to 'maintenance', telling users + # that this volume is in maintenance mode, and no action is allowed + # on this volume, e.g. attach, detach, retype, migrate, etc. + if lock_volume: + updates['status'] = db.Case( + [(volume.model.status.in_(states), + 'maintenance')], + else_=volume.model.status) + + action_track.track( + ctxt, action_track.ACTION_VOLUME_MIGRATE, + volume, "updating volume migration_status to 'starting'" + ) + result = volume.conditional_update(updates, expected, filters) + + if not result: + msg = _('Volume %s status must be available or reserved, must not ' + 'be migrating, have snapshots, be replicated, be part of ' + 'a group and destination host/cluster must be different ' + 'than the current one') % volume.id + LOG.error(msg) + raise exception.InvalidVolume(reason=msg) + + action_track.track( + ctxt, action_track.ACTION_VOLUME_MIGRATE, + volume, f"calling volume_rpcapi.migrate_volume to {dest['host']}" + ) + self.volume_rpcapi.migrate_volume(ctxt, volume, backend, + force_host_copy=False, + wait_for_completion=False) + volume.refresh() + def initialize_connection(self, context, volume, connector): context.authorize(vol_action_policy.INITIALIZE_POLICY, target_obj=volume) @@ -807,9 +955,11 @@ def initialize_connection(self, context, volume, connector): msg = _("The volume connection cannot be initialized in " "maintenance mode.") raise exception.InvalidVolume(reason=msg) + init_results = self.volume_rpcapi.initialize_connection(context, volume, connector) + LOG.info("Initialize volume connection completed successfully.", resource=volume) return init_results @@ -1062,8 +1212,10 @@ def create_snapshot(self, context, result = self._create_snapshot(context, volume, name, description, False, metadata, cgsnapshot_id, group_snapshot_id) - LOG.info("Snapshot create request issued successfully.", - resource=result) + action_track.track( + context, action_track.ACTION_SNAPSHOT_CREATE, + result, "Snapshot create request issued successfully." + ) return result def create_snapshot_force(self, context, @@ -1071,8 +1223,10 @@ def create_snapshot_force(self, context, description, metadata=None): result = self._create_snapshot(context, volume, name, description, True, metadata) - LOG.info("Snapshot force create request issued successfully.", - resource=result) + action_track.track( + context, action_track.ACTION_SNAPSHOT_CREATE, + result, "Snapshot force create request issued successfully." + ) return result def delete_snapshot(self, context, snapshot, force=False, @@ -1103,8 +1257,10 @@ def delete_snapshot(self, context, snapshot, force=False, raise exception.InvalidSnapshot(reason=msg) self.volume_rpcapi.delete_snapshot(context, snapshot, unmanage_only) - LOG.info("Snapshot delete request issued successfully.", - resource=snapshot) + action_track.track( + context, action_track.ACTION_SNAPSHOT_DELETE, + snapshot, "Snapshot delete request issued successfully." + ) def update_snapshot(self, context, snapshot, fields): context.authorize(snapshot_policy.UPDATE_POLICY, @@ -1149,6 +1305,13 @@ def _update_volume_metadata(self, context, volume, metadata, delete=False, '%s status.') % volume['status'] LOG.info(msg, resource=volume) raise exception.InvalidVolume(reason=msg) + + if ('scheduler_hint_different_host' in metadata or + 'scheduler_hint_same_host' in metadata): + raise exception.InvalidInput("Cannot add/edit volume metadata key " + "scheduler_hint_same_host or " + "scheduler_hint_different_host") + return self.db.volume_metadata_update(context, volume['id'], metadata, delete, meta_type) @@ -1510,6 +1673,20 @@ def migrate_volume(self, context, volume, host, cluster_name, force_copy, # in a cluster we will do a cluster migration. cluster_name = svc.cluster_name + # Add a check here to ensure that a volume isn't moved + # to another shard if it's attached. + # migrate_by_connector is called for nova live migration + # so this is only called by an admin manually migrating a volume + if volume.status == 'in-use': + # Check if the new host is in a different shard. + src_host = volume_utils.extract_host(volume['host'], 'host') + dst_host = volume_utils.extract_host(host, 'host') + if src_host != dst_host: + msg = ("Cannot migrate an attached volume to a different " + "host/shard") + LOG.error(msg) + raise exception.InvalidHost(reason=msg) + # Build required conditions for conditional update expected = {'status': ('available', 'in-use'), 'migration_status': self.AVAILABLE_MIGRATION_STATUS, @@ -1527,7 +1704,22 @@ def migrate_volume(self, context, volume, host, cluster_name, force_copy, else: expected['host'] = db.Not(host) - filters = [~db.volume_has_snapshots_filter()] + # SAP: If the backend's snapshots are clones, then we don't + # have to filter out volumes that have snapshots. We can find out + # if a backend has clones for snapshots by fetching the backend + # capabilities and looking for snapshot_type=='clone' + caps = self.volume_rpcapi.get_capabilities( + context, volume.service_topic_queue, discover=False) + + # SAP(walter): If snapshots are clones, then we can migrate a + # volume to another pool on the same shard. So we can ignore + # filtering on the volume having snapshots + if caps.get('snapshot_type') == 'clone': + # This has to be (), not [] as the default filters param in the + # conditional_update is () + filters = () + else: + filters = [~db.volume_has_snapshots_filter()] updates = {'migration_status': 'starting', 'previous_status': volume.model.status} @@ -1546,13 +1738,19 @@ def migrate_volume(self, context, volume, host, cluster_name, force_copy, result = volume.conditional_update(updates, expected, filters) if not result: + snaps_str = "" + if filters: + snaps_str = " have snapshots," msg = _('Volume %s status must be available or in-use, must not ' - 'be migrating, have snapshots, be replicated, be part of ' + 'be migrating,%s be replicated, be part of ' 'a group and destination host/cluster must be different ' - 'than the current one') % volume.id + 'than the current one') % (volume.id, snaps_str) LOG.error(msg) raise exception.InvalidVolume(reason=msg) + # Check if there is an affinity/antiaffinity against the volume + filter_props = volume_utils.get_scheduler_hints_from_volume(volume) + # Call the scheduler to ensure that the host exists and that it can # accept the volume volume_type = {} @@ -1566,7 +1764,8 @@ def migrate_volume(self, context, volume, host, cluster_name, force_copy, volume, cluster_name or host, force_copy, - request_spec) + request_spec, + filter_properties=filter_props) LOG.info("Migrate volume request issued successfully.", resource=volume) @@ -2033,6 +2232,11 @@ def thaw_host(self, ctxt, host, cluster_name): if not self.volume_rpcapi.thaw_host(ctxt, services[0]): return "Backend reported error during thaw_host operation." + def recount_host_stats(self, ctxt, host): + ctxt.authorize(svr_policy.RECOUNT_STATS_POLICY) + ctxt = ctxt if ctxt.is_admin else ctxt.elevated() + self.volume_rpcapi.recount_host_stats(ctxt, host) + def check_volume_filters(self, filters, strict=False): """Sets the user filter value to accepted format""" booleans = self.db.get_booleans_for_table('volume') @@ -2103,6 +2307,10 @@ def _attachment_reserve(self, ctxt, vref, instance_uuid=None): # NOTE(jdg): Reserved is a special case, we're avoiding allowing # creation of other new reserves/attachments while in this state # so we avoid contention issues with shared connections + action_track.track( + ctxt, action_track.ACTION_VOLUME_ATTACH, + vref, "called", + ) # Multiattach of bootable volumes is a special case with it's own # policy, check that here right off the bat @@ -2149,6 +2357,10 @@ def _attachment_reserve(self, ctxt, vref, instance_uuid=None): {'vol_id': vref.id, 'statuses': utils.build_or_str(expected['status']), 'current': vref.status}) + action_track.track( + ctxt, action_track.ACTION_VOLUME_ATTACH, + vref, msg, loglevel=logging.ERROR + ) raise exception.InvalidVolume(reason=msg) values = {'volume_id': vref.id, @@ -2156,6 +2368,10 @@ def _attachment_reserve(self, ctxt, vref, instance_uuid=None): 'attach_status': 'reserved', 'instance_uuid': instance_uuid} db_ref = self.db.volume_attach(ctxt.elevated(), values) + action_track.track( + ctxt, action_track.ACTION_VOLUME_ATTACH, + vref, "attach_status changed to reserved", + ) return objects.VolumeAttachment.get_by_id(ctxt, db_ref['id']) def attachment_create(self, @@ -2167,6 +2383,10 @@ def attachment_create(self, """Create an attachment record for the specified volume.""" ctxt.authorize(attachment_policy.CREATE_POLICY, target_obj=volume_ref) connection_info = {} + action_track.track( + ctxt, action_track.ACTION_VOLUME_ATTACH, + volume_ref, "attachment_create called" + ) if "error" in volume_ref.status: msg = ('Volume attachments can not be created if the volume ' 'is in an error state. ' @@ -2174,17 +2394,35 @@ def attachment_create(self, '%(volume_status)s ') % { 'volume_id': volume_ref.id, 'volume_status': volume_ref.status} - LOG.error(msg) + action_track.track( + ctxt, action_track.ACTION_VOLUME_ATTACH, + volume_ref, msg, loglevel=logging.ERROR + ) raise exception.InvalidVolume(reason=msg) attachment_ref = self._attachment_reserve(ctxt, volume_ref, instance_uuid) if connector: - connection_info = ( - self.volume_rpcapi.attachment_update(ctxt, - volume_ref, - connector, - attachment_ref.id)) + try: + action_track.track( + ctxt, action_track.ACTION_VOLUME_ATTACH, + volume_ref, "calling volume_rpc_api.attachment_update" + ) + connection_info = ( + self.volume_rpcapi.attachment_update(ctxt, + volume_ref, + connector, + attachment_ref.id)) + except Exception: + with excutils.save_and_reraise_exception(): + action_track.track( + ctxt, action_track.ACTION_VOLUME_ATTACH, + volume_ref, + "attachment_update failed, deleting attachment record", + loglevel=logging.ERROR + ) + self.attachment_delete(ctxt, attachment_ref) + attachment_ref.connection_info = connection_info # Use of admin_metadata for RO settings is deprecated @@ -2204,6 +2442,10 @@ def attachment_create(self, attachment_ref.attach_mode = attach_mode attachment_ref.save() + action_track.track( + ctxt, action_track.ACTION_VOLUME_ATTACH, + volume_ref, "attachment_create done" + ) return attachment_ref @coordination.synchronized( @@ -2226,6 +2468,10 @@ def attachment_update(self, ctxt, attachment_ref, connector): ctxt.authorize(attachment_policy.UPDATE_POLICY, target_obj=attachment_ref) volume_ref = objects.Volume.get_by_id(ctxt, attachment_ref.volume_id) + action_track.track( + ctxt, action_track.ACTION_VOLUME_ATTACH, + volume_ref, "called" + ) if "error" in volume_ref.status: msg = ('Volume attachments can not be updated if the volume ' 'is in an error state. The Volume %(volume_id)s ' @@ -2233,6 +2479,10 @@ def attachment_update(self, ctxt, attachment_ref, connector): 'volume_id': volume_ref.id, 'volume_status': volume_ref.status} LOG.error(msg) + action_track.track( + ctxt, action_track.ACTION_VOLUME_ATTACH, + volume_ref, msg, loglevel=logging.ERROR + ) raise exception.InvalidVolume(reason=msg) if (len(volume_ref.volume_attachment) > 1 and @@ -2254,9 +2504,16 @@ def attachment_update(self, ctxt, attachment_ref, connector): msg = _('duplicate connectors detected on volume ' '%(vol)s') % {'vol': volume_ref.id} - + action_track.track( + ctxt, action_track.ACTION_VOLUME_ATTACH, + volume_ref, msg, loglevel=logging.ERROR + ) raise exception.InvalidVolume(reason=msg) + action_track.track( + ctxt, action_track.ACTION_VOLUME_ATTACH, + volume_ref, "calling volume_rpcapi.attachment_update" + ) connection_info = ( self.volume_rpcapi.attachment_update(ctxt, volume_ref, @@ -2264,12 +2521,20 @@ def attachment_update(self, ctxt, attachment_ref, connector): attachment_ref.id)) attachment_ref.connection_info = connection_info attachment_ref.save() + action_track.track( + ctxt, action_track.ACTION_VOLUME_ATTACH, + volume_ref, "Complete" + ) return attachment_ref def attachment_delete(self, ctxt, attachment): ctxt.authorize(attachment_policy.DELETE_POLICY, target_obj=attachment) volume = objects.Volume.get_by_id(ctxt, attachment.volume_id) + action_track.track( + ctxt, action_track.ACTION_VOLUME_DETACH, + volume, "called" + ) if attachment.attach_status == fields.VolumeAttachStatus.RESERVED: volume_utils.notify_about_volume_usage(ctxt, @@ -2277,6 +2542,10 @@ def attachment_delete(self, ctxt, attachment): else: # Generate the detach.start notification on the volume service to # include the host doing the operation. + action_track.track( + ctxt, action_track.ACTION_VOLUME_DETACH, + volume, "call volume_rpcapi.attachment_delete" + ) self.volume_rpcapi.attachment_delete(ctxt, attachment.id, volume) # Trigger attachments lazy load (missing since volume was loaded in the @@ -2291,6 +2560,10 @@ def attachment_delete(self, ctxt, attachment): # #1937084, and doing the notification there with the finish here leads # to bug #1916980. volume_utils.notify_about_volume_usage(ctxt, volume, "detach.end") + action_track.track( + ctxt, action_track.ACTION_VOLUME_DETACH, + volume, "Completed" + ) return volume.volume_attachment diff --git a/cinder/volume/driver.py b/cinder/volume/driver.py index f1666b84cd0..719970532c8 100644 --- a/cinder/volume/driver.py +++ b/cinder/volume/driver.py @@ -405,6 +405,7 @@ def __init__(self, execute=utils.execute, *args, **kwargs): self.host = kwargs.get('host') self.cluster_name = kwargs.get('cluster_name') self.configuration = kwargs.get('configuration', None) + self.additional_endpoints = kwargs.get('additional_endpoints', []) if self.configuration: self.configuration.append_config_values(volume_opts) diff --git a/cinder/volume/drivers/netapp/dataontap/client/api.py b/cinder/volume/drivers/netapp/dataontap/client/api.py index ada92e7f107..adbe4889d22 100644 --- a/cinder/volume/drivers/netapp/dataontap/client/api.py +++ b/cinder/volume/drivers/netapp/dataontap/client/api.py @@ -20,6 +20,7 @@ Contains classes required to issue API calls to Data ONTAP and OnCommand DFM. """ import random +import ssl from eventlet import greenthread from eventlet import semaphore @@ -299,7 +300,8 @@ def _build_opener(self): auth_handler = self._create_basic_auth_handler() else: auth_handler = self._create_certificate_auth_handler() - opener = urllib.request.build_opener(auth_handler) + https_handler = self._create_no_cert_check_handler() + opener = urllib.request.build_opener(https_handler, auth_handler) self._opener = opener def _create_basic_auth_handler(self): @@ -312,6 +314,12 @@ def _create_basic_auth_handler(self): def _create_certificate_auth_handler(self): raise NotImplementedError() + def _create_no_cert_check_handler(self): + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + return urllib.request.HTTPSHandler(context=ctx) + def __str__(self): return "server: %s" % self._host diff --git a/cinder/volume/drivers/vmware/datastore.py b/cinder/volume/drivers/vmware/datastore.py index ceb56b492cf..5bdc3e540ef 100644 --- a/cinder/volume/drivers/vmware/datastore.py +++ b/cinder/volume/drivers/vmware/datastore.py @@ -17,6 +17,7 @@ Classes and utility methods for datastore selection. """ +from collections.abc import Iterable import random from oslo_log import log as logging @@ -24,6 +25,7 @@ from oslo_vmware import vim_util from cinder import coordination +from cinder import exception from cinder.volume.drivers.vmware import exceptions as vmdk_exceptions @@ -55,12 +57,15 @@ class DatastoreSelector(object): PROFILE_NAME = "storageProfileName" # TODO(vbala) Remove dependency on volumeops. - def __init__(self, vops, session, max_objects, ds_regex=None): + def __init__(self, vops, session, max_objects, ds_regex=None, + random_ds=False, random_ds_range=None): self._vops = vops self._session = session self._max_objects = max_objects self._ds_regex = ds_regex self._profile_id_cache = {} + self._random_ds = random_ds + self._random_ds_range = random_ds_range @coordination.synchronized('vmware-datastore-profile-{profile_name}') def get_profile_id(self, profile_name): @@ -96,6 +101,113 @@ def _filter_by_profile(self, datastores, profile_id): hub_ids = [hub.hubId for hub in hubs] return {k: v for k, v in datastores.items() if k.value in hub_ids} + def is_host_in_buildup_cluster(self, host_ref, host_cluster_ref=None, + cluster_cache=None): + """Check if a host is in a cluster marked as in buildup + + :param host_ref: a ManagedObjectReference to HostSystem + :param host_cluster_ref: (optional) ManagedObjectReference to + ClusterComputeResource pointing to the cluster of + the given host. Will be fetched if not given. + :param cluster_cache: (optional) dict from ManagedObjectReference value + to dict (property name, property value) for + ClusterComputeResource objects. Can be set if the + required properties for + get_cluster_custom_attributes() were prefetched + for multiple clusters. + """ + if cluster_cache is None: + cluster_cache = {} + + if host_cluster_ref is None: + host_cluster_ref = self._vops._get_parent(host_ref, + "ClusterComputeResource") + + host_cluster_value = vim_util.get_moref_value(host_cluster_ref) + + attrs = self._vops.get_cluster_custom_attributes( + host_cluster_ref, props=cluster_cache.get(host_cluster_value)) + LOG.debug("Cluster %s custom attributes: %s", + host_cluster_value, attrs) + + if not attrs or 'buildup' not in attrs: + return False + + def bool_from_str(bool_str): + return bool_str.lower() == "true" + + return bool_from_str(attrs['buildup']['value']) + + def _is_host_usable(self, host_ref, host_prop_map=None): + """Check a host's connectionState and inMaintenanceMode properties + + :param host_ref: a ManagedObjectReference to HostSystem + :param host_prop_map: (optional) a dict from ManagedObjectReference + value to a dict (property name, property value). + Can be set if the required properties were + prefetched for multiple hosts. + :return: boolean if the host is usable + """ + if host_prop_map is None: + host_prop_map = {} + + props = host_prop_map.get(host_ref.value) + if props is None: + props = self._get_host_properties(host_ref) + host_prop_map[host_ref.value] = props + + connection_state = props.get('runtime.connectionState') + in_maintenance = props.get('runtime.inMaintenanceMode') + if None in (connection_state, in_maintenance): + return False + + return (connection_state == 'connected' and + not in_maintenance) + + def _filter_hosts(self, hosts): + """Filter out hosts in buildup cluster or otherwise unusable""" + if not hosts: + return [] + + if isinstance(hosts, Iterable): + # prefetch host properties + host_properties = ['runtime.connectionState', + 'runtime.inMaintenanceMode', 'parent'] + host_prop_map = self._get_properties_for_morefs( + 'HostSystem', hosts, host_properties) + + # prefetch cluster properties + host_cluster_refs = set( + h_props['parent'] for h_props in host_prop_map.values() + if h_props.get('parent')) + cluster_prop_map = self._get_properties_for_morefs( + 'ClusterComputeResource', list(host_cluster_refs), + ['availableField', 'customValue']) + else: + host_prop_map = cluster_prop_map = None + hosts = [hosts] + + valid_hosts = [] + for host in hosts: + host_ref_value = vim_util.get_moref_value(host) + host_props = host_prop_map.get(host_ref_value, {}) + host_cluster_ref = host_props.get('parent') + if self.is_host_in_buildup_cluster( + host, host_cluster_ref=host_cluster_ref, + cluster_cache=cluster_prop_map): + continue + + if not self._is_host_usable(host, host_prop_map=host_prop_map): + continue + + valid_hosts.append(host) + + return valid_hosts + + def is_datastore_usable(self, summary): + return summary.accessible and not self._vops._in_maintenance( + summary) + def _filter_datastores(self, datastores, size_bytes, @@ -113,10 +225,6 @@ def _is_valid_ds_type(summary): (hard_affinity_ds_types is None or ds_type in hard_affinity_ds_types)) - def _is_ds_usable(summary): - return summary.accessible and not self._vops._in_maintenance( - summary) - valid_host_refs = valid_host_refs or [] valid_hosts = [host_ref.value for host_ref in valid_host_refs] @@ -145,7 +253,8 @@ def _is_ds_valid(ds_ref, ds_props): not _is_ds_accessible_to_valid_host(host_mounts)): return False - return _is_valid_ds_type(summary) and _is_ds_usable(summary) + return (_is_valid_ds_type(summary) and + self.is_datastore_usable(summary)) datastores = {k: v for k, v in datastores.items() if _is_ds_valid(k, v)} @@ -164,40 +273,100 @@ def _get_object_properties(self, obj_content): return props def _get_datastores(self): + vim = self._session.vim datastores = {} retrieve_result = self._session.invoke_api( vim_util, 'get_objects', - self._session.vim, + vim, 'Datastore', self._max_objects, properties_to_collect=['host', 'summary']) - while retrieve_result: - if retrieve_result.objects: - for obj_content in retrieve_result.objects: - props = self._get_object_properties(obj_content) - if ('host' in props and - hasattr(props['host'], 'DatastoreHostMount')): - props['host'] = props['host'].DatastoreHostMount - datastores[obj_content.obj] = props - retrieve_result = self._session.invoke_api(vim_util, - 'continue_retrieval', - self._session.vim, - retrieve_result) + with vim_util.WithRetrieval(vim, retrieve_result) as objects: + for obj_content in objects: + props = self._get_object_properties(obj_content) + if ('host' in props and + hasattr(props['host'], 'DatastoreHostMount')): + props['host'] = props['host'].DatastoreHostMount + datastores[obj_content.obj] = props return datastores + def select_datastore_by_name(self, name): + """Find a datastore by it's name. + + Returns a host_ref and datastore summary. + """ + + resource_pool = None + datastore = None + datastores = self._get_datastores() + for k, v in datastores.items(): + if v['summary'].name == name: + datastore = v + + if not datastore: + # this shouldn't ever happen as the scheduler told us + # to use this named datastore + return (None, None, None) + + summary = datastore['summary'] + # pick a host that's available + hosts = [host['key'] for host in datastore['host']] + hosts = self._filter_hosts(hosts) + if not hosts: + raise exception.InvalidInput( + "No hosts available for datastore '%s'" % name) + + host = random.choice(hosts) + + # host_ref = datastore['host'][0]['key'] + host_props = self._get_host_properties(host) + parent = host_props.get('parent') + + resource_pool = self._get_resource_pool(parent) + return (host, resource_pool, summary) + def _get_host_properties(self, host_ref): + properties = ['runtime.connectionState', 'runtime.inMaintenanceMode', + 'parent'] retrieve_result = self._session.invoke_api(vim_util, 'get_object_properties', self._session.vim, host_ref, - ['runtime', 'parent']) + properties) if retrieve_result: return self._get_object_properties(retrieve_result[0]) + def _get_properties_for_morefs(self, type_, morefs, properties): + """Fetch properties for the given morefs of type type_ + + :param type_: a ManagedObject type + :param morefs: a list of ManagedObjectReference for the given type_ + :param properties: a list of strings defining the properties to fetch + :returns: a dict of ManagedObjectReference values mapped to a dict of + (property name, property value) + """ + obj_prop_map = {} + + result = \ + self._session.invoke_api( + vim_util, + "get_properties_for_a_collection_of_objects", + self._session.vim, + type_, morefs, + properties) + with vim_util.WithRetrieval(self._session.vim, result) as objects: + for obj in objects: + props = self._get_object_properties(obj) + + obj_prop_map[vim_util.get_moref_value(obj.obj)] = { + prop: props.get(prop) for prop in properties} + + return obj_prop_map + def _get_resource_pool(self, cluster_ref): return self._session.invoke_api(vim_util, 'get_object_property', @@ -219,35 +388,29 @@ def _sort_key(ds_props): host_prop_map = {} - def _is_host_usable(host_ref): - props = host_prop_map.get(host_ref.value) - if props is None: - props = self._get_host_properties(host_ref) - host_prop_map[host_ref.value] = props - - runtime = props.get('runtime') - parent = props.get('parent') - if runtime and parent: - return (runtime.connectionState == 'connected' and - not runtime.inMaintenanceMode) - else: - return False - valid_host_refs = valid_host_refs or [] valid_hosts = [host_ref.value for host_ref in valid_host_refs] - def _select_host(host_mounts): + def _select_host(host_mounts, host_prop_map): random.shuffle(host_mounts) for host_mount in host_mounts: if valid_hosts and host_mount.key.value not in valid_hosts: continue if (self._vops._is_usable(host_mount.mountInfo) and - _is_host_usable(host_mount.key)): + self._is_host_usable(host_mount.key, + host_prop_map=host_prop_map)): return host_mount.key sorted_ds_props = sorted(datastores.values(), key=_sort_key) + if self._random_ds: + LOG.debug('Shuffling best datastore selection.') + if self._random_ds_range: + sorted_ds_props = sorted_ds_props[:self._random_ds_range] + random.shuffle(sorted_ds_props) + for ds_props in sorted_ds_props: - host_ref = _select_host(ds_props['host']) + host_ref = _select_host( + ds_props['host'], host_prop_map=host_prop_map) if host_ref: rp = self._get_resource_pool( host_prop_map[host_ref.value]['parent']) @@ -280,13 +443,18 @@ def select_datastore(self, req, hosts=None): profile_id = self.get_profile_id(profile_name) datastores = self._get_datastores() + # We don't want to use hosts in buildup + LOG.debug("FILTER hosts start {}", hosts) + valid_hosts = self._filter_hosts(hosts) + LOG.debug("FILTERED hosts valid {}", valid_hosts) datastores = self._filter_datastores(datastores, size_bytes, profile_id, hard_anti_affinity_datastores, hard_affinity_ds_types, - valid_host_refs=hosts) - res = self._select_best_datastore(datastores, valid_host_refs=hosts) + valid_host_refs=valid_hosts) + res = self._select_best_datastore(datastores, + valid_host_refs=valid_hosts) LOG.debug("Selected (host, resourcepool, datastore): %s", res) return res diff --git a/cinder/volume/drivers/vmware/exceptions.py b/cinder/volume/drivers/vmware/exceptions.py index fedfadf69e2..f7cc6144191 100644 --- a/cinder/volume/drivers/vmware/exceptions.py +++ b/cinder/volume/drivers/vmware/exceptions.py @@ -65,3 +65,8 @@ class TemplateNotFoundException(exceptions.VMwareDriverException): class SnapshotNotFoundException(exceptions.VMwareDriverException): """Thrown when the backend snapshot cannot be found.""" msg_fmt = _("Snapshot: %(name)s not found.") + + +class StorageMigrationFailed(exceptions.VMwareDriverException): + """Thrown when svmotion fails.""" + msg_fmt = _("SvMotion failed.") diff --git a/cinder/volume/drivers/vmware/fcd.py b/cinder/volume/drivers/vmware/fcd.py index b77c14b8406..a6ca812cf0a 100644 --- a/cinder/volume/drivers/vmware/fcd.py +++ b/cinder/volume/drivers/vmware/fcd.py @@ -23,6 +23,7 @@ from oslo_log import log as logging from oslo_utils import units from oslo_utils import versionutils +from oslo_vmware import exceptions as vexc from oslo_vmware import image_transfer from oslo_vmware.objects import datastore from oslo_vmware import vim_util @@ -91,7 +92,7 @@ def _select_ds_fcd(self, volume): (_host_ref, _resource_pool, summary) = self._select_datastore(req) return summary.datastore - def _get_temp_image_folder(self, size_bytes, preallocated=False): + def _get_temp_image_folder(self, name, size_bytes, preallocated=False): req = {} req[hub.DatastoreSelector.SIZE_BYTES] = size_bytes @@ -102,7 +103,7 @@ def _get_temp_image_folder(self, size_bytes, preallocated=False): (host_ref, _resource_pool, summary) = self._select_datastore(req) - folder_path = vmdk.TMP_IMAGES_DATASTORE_FOLDER_PATH + folder_path = name + '/' dc_ref = self.volumeops.get_dc(host_ref) self.volumeops.create_datastore_folder( summary.name, folder_path, dc_ref) @@ -130,8 +131,8 @@ def create_volume(self, volume): ds_ref = self._select_ds_fcd(volume) profile_id = self._get_storage_profile_id(volume) fcd_loc = self.volumeops.create_fcd( - volume.name, volume.size * units.Ki, ds_ref, disk_type, - profile_id=profile_id) + volume.id, volume.name, volume.size * units.Ki, ds_ref, + disk_type, profile_id=profile_id) return {'provider_location': fcd_loc.provider_location()} def _delete_fcd(self, provider_loc): @@ -147,7 +148,15 @@ def delete_volume(self, volume): LOG.warning("FCD provider location is empty for volume %s", volume.id) else: - self._delete_fcd(volume.provider_location) + try: + self._delete_fcd(volume.provider_location) + except vexc.VimException as ex: + if "could not be found" in str(ex): + LOG.warning("FCD deletion failed for %s not found. " + "delete_volume is considered successful.", + volume.id) + else: + raise ex def initialize_connection(self, volume, connector, initiator_data=None): """Allow connection to connector and return connection info. @@ -201,7 +210,7 @@ def copy_image_to_volume(self, context, volume, image_service, image_id): size_bytes = metadata['size'] dc_ref, summary, folder_path = self._get_temp_image_folder( - volume.size * units.Gi) + volume.name, volume.size * units.Gi) disk_name = volume.id if disk_type in [vmdk.ImageDiskType.SPARSE, vmdk.ImageDiskType.STREAM_OPTIMIZED]: @@ -229,6 +238,9 @@ def copy_image_to_volume(self, context, volume, image_service, image_id): if profile_id: self.volumeops.update_fcd_policy(fcd_loc, profile_id) + self.volumeops.update_fcd_vmdk_uuid(summary.datastore, + vmdk_path, volume.id) + return {'provider_location': fcd_loc.provider_location()} def copy_volume_to_image(self, context, volume, image_service, image_meta): @@ -298,12 +310,13 @@ def extend_volume(self, volume, new_size): volume.provider_location) self.volumeops.extend_fcd(fcd_loc, new_size * units.Ki) - def _clone_fcd(self, provider_loc, name, dest_ds_ref, + def _clone_fcd(self, provider_loc, volume, dest_ds_ref, disk_type=vops.VirtualDiskType.THIN, profile_id=None): fcd_loc = vops.FcdLocation.from_provider_location(provider_loc) - return self.volumeops.clone_fcd( - name, fcd_loc, dest_ds_ref, disk_type, profile_id=profile_id) + return self.volumeops.clone_fcd(volume, + fcd_loc, dest_ds_ref, + disk_type, profile_id=profile_id) def create_snapshot(self, snapshot): """Creates a snapshot. @@ -348,7 +361,7 @@ def _create_volume_from_fcd(self, provider_loc, cur_size, volume): disk_type = self._get_disk_type(volume) profile_id = self._get_storage_profile_id(volume) cloned_fcd_loc = self._clone_fcd( - provider_loc, volume.name, ds_ref, disk_type=disk_type, + provider_loc, volume, ds_ref, disk_type=disk_type, profile_id=profile_id) self._extend_if_needed(cloned_fcd_loc, cur_size, volume.size) return {'provider_location': cloned_fcd_loc.provider_location()} @@ -365,7 +378,7 @@ def create_volume_from_snapshot(self, volume, snapshot): if fcd_snap_loc: profile_id = self._get_storage_profile_id(volume) fcd_loc = self.volumeops.create_fcd_from_snapshot( - fcd_snap_loc, volume.name, profile_id=profile_id) + fcd_snap_loc, volume.name, volume.id, profile_id=profile_id) self._extend_if_needed(fcd_loc, snapshot.volume_size, volume.size) return {'provider_location': fcd_loc.provider_location()} else: diff --git a/cinder/volume/drivers/vmware/remote.py b/cinder/volume/drivers/vmware/remote.py new file mode 100644 index 00000000000..5582e551fa0 --- /dev/null +++ b/cinder/volume/drivers/vmware/remote.py @@ -0,0 +1,103 @@ +# Copyright (c) 2020 SAP SE +# All Rights Reserved. +# +# 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. + +""" +RPC server and client for communicating with other VMDK drivers directly. +This is the gateway which allows us gathering VMWare related information from +other hosts and perform cross vCenter operations. +""" +import oslo_messaging as messaging +from oslo_vmware import vim_util + +from cinder import rpc +from cinder.volume.rpcapi import VolumeAPI +from cinder.volume import volume_utils + + +class VmdkDriverRemoteApi(rpc.RPCAPI): + RPC_API_VERSION = VolumeAPI.RPC_API_VERSION + RPC_DEFAULT_VERSION = RPC_API_VERSION + TOPIC = VolumeAPI.TOPIC + BINARY = VolumeAPI.BINARY + + def _get_cctxt(self, host=None, version=None, **kwargs): + kwargs['server'] = volume_utils.extract_host(host) + return super(VmdkDriverRemoteApi, self)._get_cctxt(version=version, + **kwargs) + + def get_service_locator_info(self, ctxt, host): + cctxt = self._get_cctxt(host) + return cctxt.call(ctxt, 'get_service_locator_info') + + def select_ds_for_volume(self, ctxt, cinder_host, volume): + cctxt = self._get_cctxt(cinder_host) + return cctxt.call(ctxt, 'select_ds_for_volume', volume=volume, + cinder_host=cinder_host) + + def move_volume_backing_to_folder(self, ctxt, host, volume, folder): + cctxt = self._get_cctxt(host) + return cctxt.call(ctxt, 'move_volume_backing_to_folder', volume=volume, + folder=folder) + + def create_backing(self, ctxt, host, volume, create_params=None, + cinder_host=None): + cctxt = self._get_cctxt(host) + return cctxt.call(ctxt, 'create_backing', volume=volume, + create_params=create_params, + cinder_host=cinder_host or host) + + +class VmdkDriverRemoteService(object): + RPC_API_VERSION = VmdkDriverRemoteApi.RPC_API_VERSION + + target = messaging.Target(version=RPC_API_VERSION) + + def __init__(self, driver): + self._driver = driver + + def get_service_locator_info(self, ctxt): + return self._driver.service_locator_info + + def select_ds_for_volume(self, ctxt, volume, cinder_host=None): + """Select datastore for volume. + + cinder_host is a host@backend_name#pool entry. + host is an vmware host, which is returned from the driver call + to select_ds_for_volume and returned as part of this call + """ + (host, rp, folder, summary) = self._driver._select_ds_for_volume( + volume, cinder_host=cinder_host) + + profile_id = self._driver._get_storage_profile_id(volume) + + return { + 'host': host.value, + 'resource_pool': rp.value, + 'folder': folder.value, + 'datastore': summary.datastore.value, + 'profile_id': profile_id, + } + + def move_volume_backing_to_folder(self, ctxt, volume, folder): + backing = self._driver.volumeops.get_backing(volume['name'], + volume['id']) + folder_ref = vim_util.get_moref(folder, 'Folder') + self._driver.volumeops.move_backing_to_folder(backing, folder_ref) + + def create_backing(self, ctxt, volume, create_params=None, + cinder_host=None): + return self._driver._create_backing(volume, + create_params=create_params, + cinder_host=cinder_host) diff --git a/cinder/volume/drivers/vmware/vmdk.py b/cinder/volume/drivers/vmware/vmdk.py index 833de20990e..14a442f7b1a 100644 --- a/cinder/volume/drivers/vmware/vmdk.py +++ b/cinder/volume/drivers/vmware/vmdk.py @@ -24,7 +24,9 @@ import math import re +import ssl +import OpenSSL from oslo_config import cfg from oslo_log import log as logging from oslo_utils import excutils @@ -37,14 +39,17 @@ from oslo_vmware import pbm from oslo_vmware import vim_util +from cinder import context from cinder import exception from cinder.i18n import _ from cinder.image import image_utils from cinder import interface +from cinder.objects import snapshot as snapshot_obj from cinder.volume import configuration from cinder.volume import driver from cinder.volume.drivers.vmware import datastore as hub from cinder.volume.drivers.vmware import exceptions as vmdk_exceptions +from cinder.volume.drivers.vmware import remote as remote_api from cinder.volume.drivers.vmware import volumeops from cinder.volume import volume_types from cinder.volume import volume_utils @@ -68,6 +73,8 @@ EXTENSION_KEY = 'org.openstack.storage' EXTENSION_TYPE = 'volume' +LOCATION_DRIVER_NAME = 'VMwareVcVmdkDriver' + vmdk_opts = [ cfg.StrOpt('vmware_host_ip', help='IP address for connecting to VMware vCenter server.'), @@ -132,6 +139,7 @@ help='Name of a vCenter compute cluster where volumes ' 'should be created.'), cfg.MultiStrOpt('vmware_storage_profile', + default=[], help='Names of storage profiles to be monitored. Only ' 'used when vmware_enable_volume_stats is True.'), cfg.IntOpt('vmware_connection_pool_size', @@ -159,11 +167,56 @@ help='Regular expression pattern to match the name of ' 'datastores where backend volumes are created.'), cfg.BoolOpt('vmware_enable_volume_stats', - default=False, + default=True, help='If true, this enables the fetching of the volume stats ' 'from the backend. This has potential performance ' 'issues at scale. When False, the driver will not ' - 'collect ANY stats about the backend.') + 'collect ANY stats about the backend.'), + cfg.BoolOpt('vmware_online_resize', + default=True, + help='If true, enables volume resize in in-use state'), + cfg.BoolOpt('vmware_profile_check_on_attach', + default=True, + help='If False, we are not checking the storage-policy in ' + 'case of attach operation for an existing backing. This is ' + 'required to allow DS maintanance, where we remove the ' + 'storage-profile to prohibit cinder from scheduling new ' + 'volumes to that DS and move the volumes away manually. ' + 'Not disabling this would mean cinder moves the volumes ' + 'around, which can take a long time and leads to timeouts.'), + cfg.BoolOpt('vmware_select_random_best_datastore', + default=False, + help='If True, driver will randomize the picking of ' + 'best datastore from best possible datastores ' + 'during volume backing creation. Best possible datastores ' + 'are most connected hosts and most free space.'), + cfg.IntOpt('vmware_random_datastore_range', + default=None, + help='If vmware_select_random_best_datastore is enabled ' + 'this enables subselecting a range of datastores to pick from ' + 'after they have been sorted. ie. If there are 10 ' + 'datastores, and vmware_random_datastore_range is set to 5 ' + 'Then it will filter in 5 datastores prior to randomizing ' + 'the datastores to pick from.'), + cfg.BoolOpt('vmware_datastores_as_pools', + default=False, + help='Enable reporting individual datastores as pools. ' + 'This allows the cinder scheduler to pick which datastore ' + 'a volume lives on. This also enables managing capacity ' + 'for each datastore by cinder. ' + ), + cfg.BoolOpt('vmware_sap_update_provider_info', + default=False, + help='This prevents the driver from traversing all volumes ' + 'associated with a backend to ensure the pool is correct'), + cfg.StrOpt('allow_pulling_images_from_url', + default=True, + help='Allow VMware to pull images directly from Swift. ' + 'By enabling this, images that are stored in Swift will be ' + 'downloaded by VMWare from the `direct_url`, instead of the ' + 'cinder-volume container having to proxy the image between ' + 'glance and VMware.' + ), ] CONF = cfg.CONF @@ -281,7 +334,16 @@ class VMwareVcVmdkDriver(driver.VolumeDriver): # real get_volume_stats for proper scheduling of this driver. # 3.4.4 - Ensure datastores exist for storage profiles during # get_volume_stats() - VERSION = '3.4.4' + # 3.4.4.99.0 - Added reporting of thin_provisioning_support, + # max_over_subscription_ratio. + # 3.4.2.99.1 - VMware implementation of volume migration + # 3.4.2.99.2 - Added soft sharding volume migration, fixed a small issue + # in check_for_setup_error where storage_profile not set. + # 3.4.2.99.3 - Add support for reporting each datastore as a pool. + # 3.4.2.99.4 - Default to thick provisioning and report provisioning type + # based on the volume type extra specs if possible. + # 3.4.2.99.5 - Mark datastore/pool as down if it has red flag alert. + VERSION = '3.4.2.99.5' # ThirdPartySystems wiki page CI_WIKI_NAME = "VMware_CI" @@ -300,11 +362,18 @@ def __init__(self, *args, **kwargs): self._session = None self._stats = None self._volumeops = None + self._vcenter_instance_uuid_cache = None self._storage_policy_enabled = False self._ds_sel = None self._clusters = None self._dc_cache = {} self._ds_regex = None + self.additional_endpoints.extend([ + remote_api.VmdkDriverRemoteService(self) + ]) + self._remote_api = remote_api.VmdkDriverRemoteApi() + self._storage_profiles = [] + self._volume_type_by_backend = None @staticmethod def get_driver_options(): @@ -318,6 +387,14 @@ def volumeops(self): def ds_sel(self): return self._ds_sel + @property + def _vcenter_instance_uuid(self): + if self._vcenter_instance_uuid_cache: + return self._vcenter_instance_uuid_cache + self._vcenter_instance_uuid_cache = \ + self.session.vim.service_content.about.instanceUuid + return self._vcenter_instance_uuid_cache + def _validate_params(self): # Throw error if required parameters are not set. required_params = ['vmware_host_ip', @@ -329,7 +406,31 @@ def _validate_params(self): raise exception.InvalidInput(reason=reason) def check_for_setup_error(self): - pass + # make sure if the storage profile is set that it exists. + if self.configuration.vmware_storage_profile: + for storage_profile in self.configuration.vmware_storage_profile: + if self._storage_policy_enabled and storage_profile: + profile_id = self._get_storage_profile_by_name( + storage_profile) + if not profile_id: + reason = (_("Failed to find storage profile '%s'") + % storage_profile) + raise exception.InvalidInput(reason=reason) + + def _init_vendor_properties(self): + """Set some vmware specific properties.""" + + properties = {} + vendor_prefix = "vmware" + self._set_property( + properties, + f"{vendor_prefix}:snapshot_type", + "Snapshot type", + _("Specifies Type of snapshot"), + "string", + enum=["snapshot", "clone"]) + + return properties, vendor_prefix def _update_volume_stats(self): if self.configuration.safe_get('vmware_enable_volume_stats'): @@ -357,82 +458,29 @@ def _get_fake_stats(self): self._stats = data return self._stats - def _get_volume_stats(self): - """Fetch the stats about the backend. + def _get_connection_capabilities(self): + return [ + 'vmware_service_instance_uuid:%s' % + self._vcenter_instance_uuid] - This can be slow at scale, but allows - properly provisioning scheduling. - """ - backend_name = self.configuration.safe_get('volume_backend_name') - if not backend_name: - backend_name = self.__class__.__name__ - data = {'volume_backend_name': backend_name, - 'vendor_name': 'VMware', - 'driver_version': self.VERSION, - 'storage_protocol': 'vmdk', - 'reserved_percentage': self.configuration.reserved_percentage, - 'shared_targets': False} - ds_summaries = self._get_datastore_summaries() - available_hosts = self._get_hosts(self._clusters) - global_capacity = 0 - global_free = 0 - if ds_summaries: - while True: - for ds in ds_summaries.objects: - ds_props = self._get_object_properties(ds) - summary = ds_props['summary'] - if self._is_datastore_accessible(summary, - ds_props['host'], - available_hosts): - global_capacity += summary.capacity - global_free += summary.freeSpace - if getattr(ds_summaries, 'token', None): - ds_summaries = self.volumeops.continue_retrieval( - ds_summaries) - else: - break - data['total_capacity_gb'] = round(global_capacity / units.Gi) - data['free_capacity_gb'] = round(global_free / units.Gi) - self._stats = data - return data + def _collect_backend_stats(self): + """Build the call and return the results for stats.""" - def _get_datastore_summaries(self): client_factory = self.session.vim.client.factory object_specs = [] - if (self._storage_policy_enabled - and self.configuration.vmware_storage_profile): - # Get all available storage profiles on the vCenter and extract the - # IDs of those that we want to observe - profiles_ids = [] - for profile in pbm.get_all_profiles(self.session): - if profile.name in self.configuration.vmware_storage_profile: - profiles_ids.append(profile.profileId) + result = [] + if (self._storage_policy_enabled and self._storage_profiles): # Get all matching Datastores for each profile - datastores = {} - for profile_id in profiles_ids: - for pbm_hub in pbm.filter_hubs_by_profile(self.session, - None, - profile_id): - if pbm_hub.hubType != "Datastore": - # We are not interested in Datastore Clusters for now - continue - if pbm_hub.hubId not in datastores: - # Reconstruct a managed object reference to datastore - datastores[pbm_hub.hubId] = vim_util.get_moref( - pbm_hub.hubId, "Datastore") - # Build property collector object specs out of them - for datastore_ref in datastores.values(): - object_specs.append( - vim_util.build_object_spec(client_factory, - datastore_ref, - [])) - + LOG.debug("Storage Profile = '%s'", self._storage_profiles) + datastores = self._get_datastores_for_profiles() if not datastores: LOG.warning("No Datastores found for storage profile(s) " "''%s'", ', '.join( self.configuration.safe_get( 'vmware_storage_profile'))) + + return None, datastores else: # Build a catch-all object spec that would reach all datastores object_specs.append( @@ -441,44 +489,185 @@ def _get_datastore_summaries(self): self.session.vim.service_content.rootFolder, [vim_util.build_recursive_traversal_spec(client_factory)])) - # If there are no datastores, we won't have object_specs and will - # fail when trying to get stats - if not object_specs: - return + # If there are no datastores, then object specs are empty + # we can't query vcenter with empty object specs, or we'll + # get errors. + if object_specs: + prop_spec = vim_util.build_property_spec( + client_factory, 'Datastore', ['summary']) + filter_spec = vim_util.build_property_filter_spec( + client_factory, prop_spec, object_specs) + options = client_factory.create('ns0:RetrieveOptions') + max_objects = self.configuration.vmware_max_objects_retrieval + options.maxObjects = max_objects + result = self.session.vim.RetrievePropertiesEx( + self.session.vim.service_content.propertyCollector, + specSet=[filter_spec], + options=options) + + return (result, {}) + + def _get_volume_type_by_backend_name(self, backend_name): + if not self._volume_type_by_backend: + self._volume_type_by_backend = {} + ctxt = context.get_admin_context() + all_types = volume_types.get_all_types(ctxt) + for v_type_name, v_type in all_types.items(): + specs = v_type['extra_specs'] + if 'volume_backend_name' in specs: + self._volume_type_by_backend[backend_name] = v_type + + return self._volume_type_by_backend.get(backend_name, None) + + @volume_utils.trace + def _get_volume_stats(self): + backend_name = self.configuration.safe_get('volume_backend_name') + if not backend_name: + backend_name = self.__class__.__name__ + + # Force the reporting of provisioning support based + # on the volume type setting + v_type_provisioning_type = 'thick' - prop_spec = vim_util.build_property_spec(client_factory, 'Datastore', - ['summary', 'host']) - filter_spec = vim_util.build_property_filter_spec(client_factory, - prop_spec, - object_specs) - options = client_factory.create('ns0:RetrieveOptions') - options.maxObjects = self.configuration.vmware_max_objects_retrieval - result = self.session.vim.RetrievePropertiesEx( - self.session.vim.service_content.propertyCollector, - specSet=[filter_spec], - options=options) - return result - - def _get_object_properties(self, obj_content): - props = {} - if hasattr(obj_content, 'propSet'): - prop_set = obj_content.propSet - if prop_set: - props = {prop.name: prop.val for prop in prop_set} - return props - - def _is_datastore_accessible(self, ds_summary, ds_host_mounts, - available_hosts): - # available_hosts empty => vmware_cluster_name not specified => don't - # filter by hosts - cluster_access_to_ds = not available_hosts - for host_mount in ds_host_mounts.DatastoreHostMount: - for avlbl_host in available_hosts: - if avlbl_host.value == host_mount.key.value: - cluster_access_to_ds = True - return (ds_summary.accessible - and not self.volumeops._in_maintenance(ds_summary) - and cluster_access_to_ds) + # Volume type matches for this backend + v_type = self._get_volume_type_by_backend_name(backend_name) + if v_type and v_type.get('extra_specs', None): + extra_specs = v_type.get('extra_specs') + v_type_provisioning_type = extra_specs.get('provisioning:type', + 'thin') + + if v_type_provisioning_type == 'thin': + thin_provisioning_on = True + thick_provisioning_on = False + else: + thin_provisioning_on = False + thick_provisioning_on = True + + location_info = '%(driver_name)s:%(vcenter)s' % { + 'driver_name': LOCATION_DRIVER_NAME, + 'vcenter': self.session.vim.service_content.about.instanceUuid} + reserved_percentage = self.configuration.reserved_percentage + max_over_subscription_ratio = self.configuration.safe_get( + 'max_over_subscription_ratio') + + snapshot_format = self.configuration.vmware_snapshot_format + if snapshot_format == 'COW': + snapshot_type = 'snapshot' + else: + snapshot_type = 'clone' + + backend_state = 'up' + data = {'volume_backend_name': backend_name, + 'vendor_name': 'VMware', + 'driver_version': self.VERSION, + 'storage_protocol': 'vmdk', + 'location_info': location_info, + 'backend_state': backend_state, + 'snapshot_type': snapshot_type + } + + result, datastores = self._collect_backend_stats() + connection_capabilities = self._get_connection_capabilities() + if not datastores: + backend_state = 'down' + data['backend_state'] = backend_state + if self.configuration.vmware_datastores_as_pools: + pools = [] + for ds_name in datastores: + datastore = datastores[ds_name] + summary = datastore["summary"] + + pool_state = 'down' + pool_down_reason = 'Datastore not usable' + if self.ds_sel.is_datastore_usable(summary): + pool_state = 'up' + pool_down_reason = 'up' + + # make sure the datastore isn't in red flag alert! + for alarm in datastore.get('alarms', []): + for alarm_state in alarm: + if alarm_state['overallStatus'] in ('red', 'yellow'): + alarm_info = self.volumeops.get_datastore_alarm( + alarm_state['alarm'] + ) + if ('volume capacity threshold exceeded' in + alarm_info['info.description']): + # vCenter will fail to create volumes here + pool_state = 'down' + pool_down_reason = ( + alarm_info['info.description'] + ) + + # Add any custom attributes associated with the datastore + custom_attributes = {} + if "custom_attributes" in datastore: + custom_attributes = datastore['custom_attributes'] + + # A datastore can be marked as draining in vcenter + # in which case we want to mark it down. + if 'cinder_state' in custom_attributes: + cinder_pool_state = custom_attributes['cinder_state'] + if (cinder_pool_state and + cinder_pool_state.lower() == 'drain'): + pool_state = 'down' + pool_down_reason = 'Datastore marked as draining' + + pool = {'pool_name': summary.name, + 'total_capacity_gb': round( + summary.capacity / units.Gi), + 'free_capacity_gb': round( + summary.freeSpace / units.Gi), + 'thin_provisioning_support': thin_provisioning_on, + 'thick_provisioning_support': thick_provisioning_on, + 'max_over_subscription_ratio': ( + max_over_subscription_ratio), + 'reserved_percentage': reserved_percentage, + 'multiattach': False, + 'datastore_type': summary.type, + 'location_url': summary.url, + 'location_info': location_info, + 'storage_profile': datastore["storage_profile"], + 'connection_capabilities': connection_capabilities, + 'backend_state': backend_state, + 'pool_state': pool_state, + 'pool_down_reason': pool_down_reason, + 'custom_attributes': custom_attributes, + } + + pools.append(pool) + data['pools'] = pools + return data + + if (self._storage_policy_enabled and self._storage_profiles): + global_capacity = global_free = 0 + # Pools are disabled, but storage profiles are enabled. + # so we collect all the stats from all the datastores returned + for ds_name in datastores: + datastore = datastores[ds_name] + summary = datastore["summary"] + global_capacity += summary.capacity + global_free += summary.freeSpace + + else: + global_capacity = global_free = 0 + with vim_util.WithRetrieval(self.session.vim, result) as objects: + for ds in objects: + summary = ds.propSet[0].val + global_capacity += summary.capacity + global_free += summary.freeSpace + + data_no_pools = { + 'reserved_percentage': self.configuration.reserved_percentage, + 'total_capacity_gb': round(global_capacity / units.Gi), + 'free_capacity_gb': round(global_free / units.Gi), + 'thin_provisioning_support': True, + 'thick_provisioning_support': True, + 'max_over_subscription_ratio': max_over_subscription_ratio, + 'connection_capabilities': connection_capabilities, + } + data.update(data_no_pools) + + return data def _verify_volume_creation(self, volume): """Verify that the volume can be created. @@ -521,6 +710,11 @@ def _delete_volume(self, volume): :param volume: Volume object """ backing = self.volumeops.get_backing(volume['name'], volume['id']) + if not backing: + # If a volume has just been migrated, the manager assigned the + # temporary ID in the `volume` parameter, but instead it has set + # the correct ID to _name_id, which we need to perform deletion. + backing = self.volumeops.get_backing_by_uuid(volume.name_id) if not backing: LOG.info("Backing not available, no operation " "to be performed.") @@ -583,20 +777,25 @@ def _get_disk_type(volume): return VMwareVcVmdkDriver._get_extra_spec_disk_type( volume['volume_type_id']) + def _get_storage_profile_by_name(self, storage_profile): + profile = pbm.get_profile_id_by_name(self.session, storage_profile) + if profile: + return profile.uniqueId + def _get_storage_profile_id(self, volume): storage_profile = self._get_storage_profile(volume) profile_id = None if self._storage_policy_enabled and storage_profile: - profile = pbm.get_profile_id_by_name(self.session, storage_profile) - if profile: - profile_id = profile.uniqueId + profile_id = self._get_storage_profile_by_name(storage_profile) return profile_id def _get_extra_config(self, volume): return {EXTRA_CONFIG_VOLUME_ID_KEY: volume['id'], volumeops.BACKING_UUID_KEY: volume['id']} - def _create_backing(self, volume, host=None, create_params=None): + @volume_utils.trace + def _create_backing(self, volume, host=None, create_params=None, + cinder_host=None): """Create volume backing under the given host. If host is unspecified, any suitable host is selected. @@ -605,11 +804,15 @@ def _create_backing(self, volume, host=None, create_params=None): :param host: Reference of the host :param create_params: Dictionary specifying optional parameters for backing VM creation + :param cinder_host: String of the format host@backend_name#pool. :return: Reference to the created backing """ create_params = create_params or {} + (host_ref, resource_pool, folder, - summary) = self._select_ds_for_volume(volume, host) + summary) = self._select_ds_for_volume(volume, host, + create_params=create_params, + cinder_host=cinder_host) # check if a storage profile needs to be associated with the backing VM profile_id = self._get_storage_profile_id(volume) @@ -654,6 +857,16 @@ def _create_backing(self, volume, host=None, create_params=None): extra_config=extra_config) self.volumeops.update_backing_disk_uuid(backing, volume['id']) + if (self.configuration.vmware_datastores_as_pools and + self._is_pool_outdated_for_volume(volume)): + # TODO(walt) - this writes the volume update to the db. :( + # This sucks, but don't have any other way + new_host = self._new_host_for_volume(volume) + LOG.info("Changing volume host from %(old_host)s to %(new_host)s", + {'old_host': volume.host, 'new_host': new_host}) + model_update = {'host': new_host} + volume.update(model_update) + volume.save() return backing def _get_hosts(self, clusters): @@ -678,7 +891,6 @@ def _select_datastore(self, req, host=None): LOG.error("There are no valid hosts available in " "configured cluster(s): %s.", self._clusters) raise vmdk_exceptions.NoValidHostException() - best_candidate = self.ds_sel.select_datastore(req, hosts=hosts) if not best_candidate: LOG.error("There is no valid datastore satisfying " @@ -694,35 +906,142 @@ def _get_dc(self, resource_pool): self._dc_cache[resource_pool.value] = dc return dc - def _select_ds_for_volume(self, volume, host=None, create_params=None): + @volume_utils.trace + def _select_ds_by_name_for_volume(self, datastore_name, volume): + + # we need a host_ref, resource_pool, folder and summary + (host_ref, + resource_pool, + summary) = self.ds_sel.select_datastore_by_name(datastore_name) + + if not summary: + # couldn't find the datastore by name + return (None, None, None, None) + + # Get the host_ref + dc = self._get_dc(resource_pool) + folder = self._get_volume_group_folder(dc, volume['project_id']) + + return (host_ref, resource_pool, folder, summary) + + @volume_utils.trace + def _is_pool_outdated_for_volume(self, volume): + """When datastores as pools is enabled. + + This check determines if the pool name is the same as the + volume_backend_name when vmware_datastores_as_pools is enabled. + + This can happen when lazy create is enabled, vmware_datastores_as_pools + is disabled and a volume is created. There will be no backing for + the volume. Then you enable vmware_datastores_as_pools and the driver + tries to automatically update the host entry for the volume. Since + the volume has no backing, there is no datastore chosen for the + volume, and then the host will still remain as + service@backend_name#backend_name instead of + service@backend_name#pool + + We have to ensure that if we do create the backing finally for + this volume, that we force a cinder db update for the host. + """ + + host_entry = volume_utils.extract_host(volume['host'], 'backend') + backend_name = host_entry.split('@')[1] + datastore_name = volume_utils.extract_host(volume['host'], 'pool') + if self.configuration.vmware_datastores_as_pools: + return backend_name == datastore_name + else: + return False + + @volume_utils.trace + def _select_ds_for_volume(self, volume, host=None, create_params=None, + cinder_host=None): """Select datastore that can accommodate the given volume's backing. + host is a vmware esxi host + cinder_host is a service@backend_name#pool + Returns the selected datastore summary along with a compute host and its resource pool and folder where the volume can be created :return: (host, resource_pool, folder, summary) """ - # Form requirements for datastore selection. - create_params = create_params or {} - size = create_params.get(CREATE_PARAM_DISK_SIZE, volume['size']) + datastore_picked = False + if self.configuration.vmware_datastores_as_pools: + # we pick the datastore from the pool name + if not cinder_host: + cinder_host = volume['host'] + + host_entry = volume_utils.extract_host(cinder_host, 'backend') + host_parts = host_entry.split('@') + datastore_name = volume_utils.extract_host(cinder_host, 'pool') + # we might be a volume that has no backing yet that couldn't + # have had their host entry updated due to lazy create. + # IF so the backend name and pool name are the same, so a + # datastore wasn't picked for this volume. + if datastore_name != host_parts[1]: + (host_ref, resource_pool, + folder, summary) = self._select_ds_by_name_for_volume( + datastore_name, volume) + if summary: + # we were able to use the datastore from the host entry + # so don't use fallback. + datastore_picked = True + else: + raise exception.InvalidInput( + "Couldn't find datastore with name '%s'" % + datastore_name) + else: + LOG.info("Volume backend name and pool name are same. Using " + "Fallback mechanism to pick a datastore.") - req = {} - req[hub.DatastoreSelector.SIZE_BYTES] = size * units.Gi - req[hub.DatastoreSelector.PROFILE_NAME] = self._get_storage_profile( - volume) + if not datastore_picked: + # Form requirements for datastore selection. + create_params = create_params or {} + size = create_params.get(CREATE_PARAM_DISK_SIZE, volume['size']) - (host_ref, resource_pool, summary) = self._select_datastore(req, host) - dc = self._get_dc(resource_pool) - folder = self._get_volume_group_folder(dc, volume['project_id']) + req = {} + req[hub.DatastoreSelector.SIZE_BYTES] = size * units.Gi + req[hub.DatastoreSelector.PROFILE_NAME] = ( + self._get_storage_profile(volume) + ) + + (host_ref, resource_pool, summary) = self._select_datastore(req, + host) + dc = self._get_dc(resource_pool) + folder = self._get_volume_group_folder(dc, volume['project_id']) return (host_ref, resource_pool, folder, summary) + @property + def service_locator_info(self): + """Returns information needed to build a ServiceLocator spec.""" + # vCenter URL + host = self.configuration.vmware_host_ip + port = self.configuration.vmware_host_port + url = "https://" + host + if port: + url += ":" + str(port) + # ssl thumbprint + cert = ssl.get_server_certificate((host, port or 443)) + x509 = OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_PEM, + cert) + return { + 'url': url, + 'ssl_thumbprint': x509.digest("sha1"), + 'instance_uuid': self._vcenter_instance_uuid, + 'credential': { + 'username': self.configuration.vmware_host_username, + 'password': self.configuration.vmware_host_password + } + } + def _get_connection_info(self, volume, backing, connector): connection_info = {'driver_volume_type': 'vmdk'} connection_info['data'] = { 'volume': backing.value, 'volume_id': volume.id, 'name': volume.name, - 'profile_id': self._get_storage_profile_id(volume) + 'profile_id': self._get_storage_profile_id(volume), + 'datastore': self.volumeops.get_datastore(backing).value, } # vmdk connector in os-brick needs additional connection info. @@ -732,9 +1051,6 @@ def _get_connection_info(self, volume, backing, connector): vmdk_path = self.volumeops.get_vmdk_path(backing) connection_info['data']['vmdk_path'] = vmdk_path - datastore = self.volumeops.get_datastore(backing) - connection_info['data']['datastore'] = datastore.value - datacenter = self.volumeops.get_dc(backing) connection_info['data']['datacenter'] = datacenter.value @@ -754,6 +1070,15 @@ def _get_connection_info(self, volume, backing, connector): } connection_info['data']['config'] = vmdk_connector_config + # instruct os-brick to use ImportVApp and HttpNfc upload for + # disconnecting the volume + # + # If we are migrating to this volume, we need to + # create a writeable handle for the migration to work. + if self._is_volume_subject_to_import_vapp(volume): + connection_info['data']['import_data'] = \ + self._get_connection_import_data(volume) + LOG.debug("Returning connection_info (volume: '%(volume)s', volume_id:" " '%(volume_id)s'), profile_id: '%(profile_id)s' for " "connector: %(connector)s.", @@ -764,6 +1089,63 @@ def _get_connection_info(self, volume, backing, connector): return connection_info + def _is_volume_subject_to_import_vapp(self, volume): + return (volume['status'] == 'restoring-backup' or + (volume['status'] == 'available' and + volume['migration_status'] and + volume['migration_status'].startswith('target:'))) + + def _get_connection_import_data(self, volume): + (host, rp, folder, summary) = self._select_ds_for_volume( + volume) + extra_config = self._get_extra_config(volume) + if volumeops.BACKING_UUID_KEY in extra_config: + extra_config.pop(volumeops.BACKING_UUID_KEY) + disk_type = VMwareVcVmdkDriver._get_disk_type(volume) + size_kb = volume['size'] * units.Mi + adapter_type = self._get_adapter_type(volume) + controller_type = volumeops.ControllerType.get_controller_type( + adapter_type) + controller_key, controller_spec = \ + self.volumeops.get_controller_key_and_spec(adapter_type) + return { + 'folder': folder.value, + 'resource_pool': rp.value, + 'vm': { + 'path_name': self.volumeops.get_vm_path_name( + summary.name), + 'guest_id': volumeops.VM_GUEST_ID, + 'num_cpus': volumeops.VM_NUM_CPUS, + 'memory_mb': volumeops.VM_MEMORY_MB, + 'vmx_version': self.volumeops.get_vmx_version(), + 'extension_key': self.volumeops._extension_key, + 'extension_type': self.volumeops._extension_type, + 'extra_config': extra_config, + }, + 'adapter_type': adapter_type, + 'controller': { + 'type': controller_type, + 'key': controller_key, + 'create': controller_spec is not None, + 'shared_bus': + self.volumeops.get_controller_device_shared_bus( + controller_type), + 'bus_number': volumeops.CONTROLLER_DEVICE_BUS_NUMBER + }, + 'disk': { + 'type': disk_type, + 'key': self.volumeops.get_disk_device_key( + controller_key), + 'capacity_in_kb': + self.volumeops.get_disk_capacity_in_kb(size_kb), + 'eagerly_scrub': + self.volumeops.get_disk_eagerly_scrub(disk_type), + 'thin_provisioned': + self.volumeops.get_disk_thin_provisioned(disk_type) + + } + } + def _initialize_connection(self, volume, connector): """Get information of volume's backing. @@ -773,7 +1155,17 @@ def _initialize_connection(self, volume, connector): :param connector: Connector information :return: Return connection information """ + # Check that connection_capabilities match + # This ensures the connector is bound to the same vCenter service + if 'connection_capabilities' in connector: + missing = set(self._get_connection_capabilities()) -\ + set(connector['connection_capabilities']) + if missing: + raise exception.ConnectorRejected( + reason="Connector is missing %s" % ', '.join(missing)) + backing = self.volumeops.get_backing(volume.name, volume.id) + if 'instance' in connector: # The instance exists instance = vim_util.get_moref(connector['instance'], @@ -805,6 +1197,7 @@ def _initialize_connection(self, volume, connector): return self._get_connection_info(volume, backing, connector) + @volume_utils.trace def initialize_connection(self, volume, connector): """Allow connection to connector and return connection info. @@ -825,8 +1218,18 @@ def initialize_connection(self, volume, connector): """ return self._initialize_connection(volume, connector) + @volume_utils.trace def terminate_connection(self, volume, connector, force=False, **kwargs): - pass + # Checking if the connection was used to restore from a backup. In + # that case, the VMDK connector in os-brick created a new backing + # which will replace the initial one. Here we set the proper name + # and backing uuid for the new backing, because os-brick doesn't do it. + if (connector and 'platform' in connector and 'os_type' in connector + and self._is_volume_subject_to_import_vapp(volume)): + backing = self.volumeops.get_backing_by_uuid(volume['id']) + + self.volumeops.rename_backing(backing, volume['name']) + self.volumeops.update_backing_disk_uuid(backing, volume['id']) def create_export(self, context, volume, connector): pass @@ -842,10 +1245,21 @@ def _get_snapshot_group_folder(self, volume, backing): return self._get_volume_group_folder( dc, volume.project_id, snapshot=True) - def _create_snapshot_template_format(self, snapshot, backing): + @volume_utils.trace + def _create_snapshot_template_format(self, snapshot, backing, + backend=None): volume = snapshot.volume folder = self._get_snapshot_group_folder(volume, backing) - datastore = self.volumeops.get_datastore(backing) + if backend: + # Create the snapshot on the datastore described in + # backend making this snapshot independent from the volume + datastore_name = volume_utils.extract_host(backend, 'pool') + (host_ref, + resource_pool, + summary) = self.ds_sel.select_datastore_by_name(datastore_name) + datastore = summary.datastore + else: + datastore = self.volumeops.get_datastore(backing) if self._in_use(volume): tmp_backing = self._create_temp_backing_from_attached_vmdk( @@ -874,6 +1288,11 @@ def _create_snapshot(self, snapshot): :param snapshot: Snapshot object """ + backend = None + key = snapshot_obj.SAP_HIDDEN_BACKEND_KEY + if ('metadata' in snapshot and snapshot['metadata'] and + key in snapshot['metadata']): + backend = snapshot['metadata'][key] volume = snapshot['volume'] snapshot_format = self.configuration.vmware_snapshot_format @@ -896,7 +1315,7 @@ def _create_snapshot(self, snapshot): snapshot['display_description']) else: model_update = self._create_snapshot_template_format( - snapshot, backing) + snapshot, backing, backend=backend) LOG.info("Successfully created snapshot: %s.", snapshot['name']) return model_update @@ -947,7 +1366,13 @@ def _delete_snapshot(self, snapshot): raise exception.InvalidSnapshot(reason=msg) else: if is_template: - self._delete_snapshot_template_format(snapshot) + try: + self._delete_snapshot_template_format(snapshot) + except vmdk_exceptions.TemplateNotFoundException: + # Just raise a warning and move on like the snap + # was deleted. If it's not there, it's already gone. + LOG.warning("Failed to find template for snapshot %s", + snapshot.id) else: self.volumeops.delete_snapshot(backing, snapshot.name) @@ -1362,13 +1787,11 @@ def _fetch_stream_optimized_image(self, context, volume, image_service, profile_id = self._get_storage_profile_id(volume) disk_type = VMwareVcVmdkDriver._get_disk_type(volume) - # The size of stream optimized glance image is often suspect, - # so better let vCenter figure out the disk capacity during import. - dummy_disk_size = 0 + size_kb = size_gb * units.Gi / units.Ki extra_config = self._get_extra_config(volume) vm_create_spec = self.volumeops.get_create_spec( volume['name'], - dummy_disk_size, + size_kb, disk_type, summary.name, profile_id=profile_id, @@ -1384,8 +1807,12 @@ def _fetch_stream_optimized_image(self, context, volume, image_service, timeout = self.configuration.vmware_image_transfer_timeout_secs host_ip = self.configuration.vmware_host_ip port = self.configuration.vmware_host_port + allow_url = self.configuration.allow_pulling_images_from_url LOG.debug("Fetching glance image: %(id)s to server: %(host)s.", {'id': image_id, 'host': host_ip}) + if allow_url: + LOG.debug("Downloading images directly from URL was enabled " + "by `allow_pulling_images_from_url`") backing = image_transfer.download_stream_optimized_image( context, timeout, @@ -1398,7 +1825,8 @@ def _fetch_stream_optimized_image(self, context, volume, image_service, vm_folder=folder, vm_import_spec=vm_import_spec, image_size=image_size, - http_method='POST') + http_method='POST', + allow_pull_from_url=allow_url) self.volumeops.update_backing_disk_uuid(backing, volume['id']) except (exceptions.VimException, exceptions.VMwareDriverException): @@ -1426,6 +1854,20 @@ def _extend_backing(self, backing, new_size_in_gb, disk_type): eager_zero = disk_type == EAGER_ZEROED_THICK_VMDK_TYPE self.volumeops.extend_virtual_disk(new_size_in_gb, root_vmdk_path, datacenter, eager_zero) + self.volumeops.reload_backing(backing) + + def _extend_backing_online(self, backing, new_size_in_gb, attachedvm): + """Extend volume backing's virtual disk online + + :param backing: volume backing + :param new_size_in_gb: new size of virtual disk + :param attachedvm: the id of the vm where the virtual disk is attached + """ + root_vmdk_path = self.volumeops.get_vmdk_path(backing) + self.volumeops.extend_virtual_disk_online(new_size_in_gb, + root_vmdk_path, + attachedvm) + self.volumeops.reload_backing(backing) def clone_image(self, context, volume, image_location, image_meta, image_service): @@ -1780,8 +2222,24 @@ def extend_volume(self, volume, new_size): LOG.info("There is no backing for volume: %s; no need to " "extend the virtual disk.", vol_name) return - - # try extending vmdk in place + if (self._in_use(volume) and not volume['multiattach'] and + self.configuration.vmware_online_resize): + attachments = volume.volume_attachment + instance_uuid = attachments[0]['instance_uuid'] + attachedvm = self.volumeops.get_backing_by_uuid(instance_uuid) + try: + self._extend_backing_online(backing, new_size, attachedvm) + LOG.info("Successfully extended volume: %(vol)s to size: " + "%(size)s GB.", + {'vol': vol_name, 'size': new_size}) + return + except exceptions.NoDiskSpaceException: + LOG.warning("Unable to extend volume: %(vol)s to size: " + "%(size)s on current datastore due to insufficient" + " space.", + {'vol': vol_name, 'size': new_size}) + return + # try extending vmdk in place offline try: self._extend_backing(backing, new_size, VMwareVcVmdkDriver._get_disk_type(volume)) @@ -2029,8 +2487,13 @@ def do_setup(self, context): max_objects = self.configuration.vmware_max_objects_retrieval self._volumeops = volumeops.VMwareVolumeOps( self.session, max_objects, EXTENSION_KEY, EXTENSION_TYPE) + random_ds = self.configuration.vmware_select_random_best_datastore + random_ds_range = self.configuration.vmware_random_datastore_range self._ds_sel = hub.DatastoreSelector( - self.volumeops, self.session, max_objects, ds_regex=self._ds_regex) + self.volumeops, self.session, max_objects, + ds_regex=self._ds_regex, + random_ds=random_ds, + random_ds_range=random_ds_range) # Get clusters to be used for backing VM creation. cluster_names = self.configuration.vmware_cluster_name @@ -2041,10 +2504,196 @@ def do_setup(self, context): self.volumeops.build_backing_ref_cache() + # Cache the storage profiles, so we don't + # have to fetch them every time. + if self.configuration.vmware_storage_profile: + self._get_storage_profiles() + LOG.info("Successfully setup driver: %(driver)s for server: " "%(ip)s.", {'driver': self.__class__.__name__, 'ip': self.configuration.vmware_host_ip}) + def _get_storage_profiles(self): + """Fetch the list of configured storage profiles we use.""" + + LOG.debug("Profiles = '%s'", self.configuration.vmware_storage_profile) + for profile in pbm.get_all_profiles(self.session): + if profile.name in self.configuration.vmware_storage_profile: + profile_dict = {"name": profile.name, + "id": profile.profileId} + self._storage_profiles.append(profile_dict) + + def _volume_provider_metadata(self, volume, backing=None): + if not backing: + backing = self.volumeops.get_backing(volume.name, volume.id) + + ds = self.volumeops.get_datastore(backing) + summary = self.volumeops.get_summary(ds) + profile = self._get_storage_profile(volume) + vcenter_uuid = ( + self.session.vim.service_content.about.instanceUuid + ) + provider_info = { + 'vmware_vcenter_id': vcenter_uuid, + 'vmware_ds_name': summary.name, + 'vmware_profile_name': profile, + } + + return provider_info + + def _get_datastores_for_profiles(self): + datastores = {} + for profile in self._storage_profiles: + for h in pbm.filter_hubs_by_profile(self.session, + None, + profile['id']): + if h.hubType != "Datastore": + # We are not interested in Datastore Clusters for now + continue + if h.hubId not in datastores: + # Reconstruct a managed object reference to that + # datastore + ds = vim_util.get_moref(h.hubId, "Datastore") + objects = self.volumeops.get_datastore_properties(ds) + summary = objects['summary'] + datastores[summary.name] = {'summary': summary, + 'storage_profile': profile, + 'datastore_object': ds} + if ('availableField' in objects and + 'customValue' in objects): + custom_fields = {} + for junk, field in objects['availableField']: + for v in field: + custom_fields[v.key] = v.name + + custom_attributes = {} + for junk, attr in objects['customValue']: + for v in attr: + field = custom_fields[v.key] + custom_attributes[field] = v.value + datastores[summary.name][ + "custom_attributes"] = custom_attributes + + if ('triggeredAlarmState' in objects and + objects['triggeredAlarmState']): + alarms = [] + for (_x, alarm) in objects['triggeredAlarmState']: + alarms.append(alarm) + datastores[summary.name]['alarms'] = alarms + return datastores + + def _new_host_for_volume(self, volume): + pool_info = volume_utils.extract_host( + volume.host, level='pool', default_pool_name=True) + model = self._volume_provider_metadata(volume) + if pool_info != model['vmware_ds_name']: + host = volume_utils.extract_host( + volume.host, level='host') + back = volume_utils.extract_host(volume.host) + backend = back.split('@')[1] + + new_host = '{}@{}#{}'.format( + host, backend, + model['vmware_ds_name'] + ) + return new_host + + @volume_utils.trace + def update_provider_info(self, volumes, snapshots): + """Ensure we have a provider_id set on volumes. + + If there is a provider_id already set then skip, if it is missing then + we will update it based on the volume object. We can always compute + the id if we have the full volume object, but not all driver API's + give us that info. + + We have to save each volume entry if they update their host, otherwise + cinder volume manager doesn't see that volume as part of the host + allocated_capacity calculation, which happens right after this call + completes. + + We don't care about snapshots, they just use the volume's provider_id. + """ + LOG.info("HOST %(host)s : volumes %(volumes_count)s", + {'host': self.host, 'volumes_count': len(volumes)}) + if not self.configuration.vmware_sap_update_provider_info: + LOG.info("Not updating provider information") + return [], None + + if self.configuration.vmware_datastores_as_pools: + LOG.info("vmware_datastores_as_pools is enabled. " + "Checking host entries for volumes and snapshots.") + datastores = self._get_datastores_for_profiles() + ds_keys = datastores.keys() + vol_updates = [] + LOG.info("Process %s volumes", len(volumes)) + for vol in volumes: + # make sure we have the correc host info + if vol['status'] in ['in-use', 'available']: + # do we need to update the host? + pool_info = volume_utils.extract_host( + vol.host, level='pool', default_pool_name=True) + + # IF the pool has already been set correctly, then + # no need to make api calls to vcenter to fetch the + # datastore name from the volume backing information. + # this will save time on every startup + if (pool_info not in ds_keys or + pool_info == volume_utils.DEFAULT_POOL_NAME): + LOG.debug("Updating host for volume %s", vol.id) + + try: + new_host = self._new_host_for_volume(vol) + if new_host: + vol.update({'host': new_host}) + vol.save() + except Exception as ex: + LOG.warning("Couldn't update host for %(id)s " + "because %(error)s", + {'id': vol.id, 'error': ex}) + else: + LOG.debug("Keeping host for volume %s", vol.id) + + LOG.info("HOST COMPLETE %s", self.host) + return vol_updates, None + else: + # Since pools are not enabled, we should ensure that the datastore + # isn't part of the host. This allows us to go backwards to + # not using datastores as pools. + LOG.info("vmware_datastores_as_pools is disabled.") + + vol_updates = [] + for vol in volumes: + # make sure we have the correc host info + if vol['status'] in ['in-use', 'available']: + # do we need to update the host? + pool_info = volume_utils.extract_host( + vol.host, level='pool') + backend_info = volume_utils.extract_host( + vol.host, level='backend' + ) + backend = backend_info.split("@") + LOG.info("Volume host '%(host)s' backend '%(backend)s' " + "pool '%(pool)s'", + {'host': vol.host, + 'backend': backend[1], + 'pool': pool_info}) + + # we need to force the host back to + # host@backend#backend + new_host = "{}@{}#{}".format( + backend[0], + backend[1], + backend[1] + ) + if new_host != vol.host: + LOG.info("Setting host to %s", new_host) + vol.update({'host': new_host}) + vol.save() + + LOG.info("HOST COMPLETE %s", self.host) + return vol_updates, None + def _get_volume_group_folder(self, datacenter, project_id, snapshot=False): """Get inventory folder for organizing volume backings and snapshots. @@ -2088,9 +2737,12 @@ def _relocate_backing(self, volume, backing, host): backing_profile = None if self._storage_policy_enabled: backing_profile = self._get_storage_profile(volume) + is_compliant = True + if self.configuration.vmware_profile_check_on_attach: + is_compliant = self.ds_sel.is_datastore_compliant(datastore, + backing_profile) if (self.volumeops.is_datastore_accessible(datastore, host) and - self.ds_sel.is_datastore_compliant(datastore, - backing_profile)): + is_compliant): LOG.debug("Datastore: %(datastore)s of backing: %(backing)s is " "already accessible to instance's host: %(host)s.", {'backing': backing, @@ -2166,9 +2818,11 @@ def _clone_backing(self, volume, backing, snapshot, clone_type, src_vsize): host = None rp = None folder = None - if clone_type != volumeops.LINKED_CLONE_TYPE: - # Pick a datastore where to create the full clone under any host - (host, rp, folder, summary) = self._select_ds_for_volume(volume) + if not clone_type == volumeops.LINKED_CLONE_TYPE: + # Pick a datastore where to create the full clone under + # any host + (host, rp, folder, summary) = self._select_ds_for_volume( + volume) datastore = summary.datastore extra_config = self._get_extra_config(volume) clone = self.volumeops.clone_backing(volume['name'], backing, @@ -2198,6 +2852,7 @@ def _clone_backing(self, volume, backing, snapshot, clone_type, src_vsize): VMwareVcVmdkDriver._get_disk_type(volume)) LOG.info("Successfully created clone: %s.", clone) + @volume_utils.trace def _create_volume_from_template(self, volume, path): LOG.debug("Creating backing for volume: %(volume_id)s from template " "at path: %(path)s.", @@ -2207,18 +2862,29 @@ def _create_volume_from_template(self, volume, path): # Create temporary backing by cloning the template. tmp_name = uuidutils.generate_uuid() + (host, rp, folder, summary) = self._select_ds_for_volume(volume) datastore = summary.datastore disk_type = VMwareVcVmdkDriver._get_disk_type(volume) - tmp_backing = self.volumeops.clone_backing(tmp_name, - template, - None, - volumeops.FULL_CLONE_TYPE, - datastore, - disk_type=disk_type, - host=host, - resource_pool=rp, - folder=folder) + device_changes = None + if volume['size']: + new_size_in_kb = int(volume['size'] * units.Gi / units.Ki) + disk_device = self.volumeops._get_disk_device(template) + if new_size_in_kb > disk_device.capacityInKB: + device_changes = self.volumeops._create_spec_for_disk_expand( + disk_device, new_size_in_kb) + + tmp_backing = self.volumeops.clone_backing( + tmp_name, + template, + None, + volumeops.FULL_CLONE_TYPE, + datastore, + disk_type=disk_type, + host=host, + resource_pool=rp, + folder=folder, + device_changes=device_changes) self._create_volume_from_temp_backing(volume, tmp_backing) @@ -2284,10 +2950,19 @@ def _create_temp_backing_from_attached_vmdk( 'instance': instance}) tmp_name = tmp_name or uuidutils.generate_uuid() + + device_changes = self.volumeops._create_device_change_for_disk_removal( + instance, disks_to_clone=[vol_dev_uuid]) + device_changes.extend( + self.volumeops._create_device_change_for_vif_removal(instance)) + + # Remove another attribute by which the nova driver identifies VMs + extra_config = {'nvp.vm-uuid': ''} + return self.volumeops.clone_backing( tmp_name, instance, None, volumeops.FULL_CLONE_TYPE, datastore, host=host, resource_pool=rp, folder=folder, - disks_to_clone=[vol_dev_uuid]) + device_changes=device_changes, extra_config=extra_config) def _extend_if_needed(self, volume, backing): volume_size = volume.size * units.Gi @@ -2389,3 +3064,144 @@ def revert_to_snapshot(self, context, volume, snapshot): LOG.debug("Backing does not exist for volume.", resource=volume) else: self.volumeops.revert_to_snapshot(backing, snapshot.name) + + @volume_utils.trace + def migrate_volume(self, context, volume, host): + """Migrate a volume to the specified host. + + If the backing is not created, returns success. + """ + + false_ret = (False, None) + allowed_statuses = ['available', 'reserved', 'in-use', 'maintenance', + 'extending'] + if volume['status'] not in allowed_statuses: + LOG.debug('Only %s volumes can be migrated using backend ' + 'assisted migration. Falling back to generic migration.', + " or ".join(allowed_statuses)) + return false_ret + + if 'location_info' not in host['capabilities']: + return false_ret + info = host['capabilities']['location_info'] + try: + (driver_name, vcenter) = info.split(':') + except ValueError: + return false_ret + + if driver_name != LOCATION_DRIVER_NAME: + return false_ret + + backing = self.volumeops.get_backing(volume.name, volume.id) + dest_host = host['host'] + # If the backing is not yet created, there is no need to migrate + if not backing: + LOG.info("There is no backing for the volume: %(volume_name)s. " + "No need for a migration. The volume will be assigned to" + " %(dest_host)s.", + {'volume_name': volume.name, 'dest_host': dest_host}) + return (True, None) + + if volume['attach_status'] == 'attached': + if self._vcenter_instance_uuid != vcenter: + return self._migrate_attached_cross_vc(context, dest_host, + volume, backing) + else: + return self._migrate_attached_same_vc(context, dest_host, + volume, backing) + else: + return self._migrate_unattached(context, dest_host, volume, + backing) + + def _migrate_attached_same_vc(self, context, dest_host, volume, backing): + get_vm_by_uuid = self.volumeops.get_backing_by_uuid + # reusing the get_backing_by_uuid to lookup the attacher vm + if volume['multiattach']: + raise NotImplementedError() + attachments = volume.volume_attachment + instance_uuid = attachments[0]['instance_uuid'] + attachedvm = get_vm_by_uuid(instance_uuid) + ds_info = self._remote_api.select_ds_for_volume(context, + cinder_host=dest_host, + volume=volume) + rp_ref = vim_util.get_moref(ds_info['resource_pool'], 'ResourcePool') + ds_ref = vim_util.get_moref(ds_info['datastore'], 'Datastore') + self.volumeops.relocate_one_disk(attachedvm, ds_ref, rp_ref, + volume_id=volume.id, + profile_id=ds_info.get('profile_id')) + new_disk = self.volumeops.get_disk_by_uuid(attachedvm, volume.id) + new_vmdk = new_disk.backing.fileName + # VMware does not update shadowvm backing after svmotion, + # So we need to fall reconfigure_backing_vmdk_path to fix + self.volumeops.reconfigure_backing_vmdk_path(backing, new_vmdk) + self.volumeops.relocate_backing(backing, ds_ref, None, None) + # VMware is locking the vmdk, so there is no posibility + # to update the profile from the backing, but the relocate_backing + # can still move the "skeletion" part of the backing to the new DS + + return (True, None) + + def _migrate_unattached(self, context, dest_host, volume, backing): + ds_info = self._remote_api.select_ds_for_volume(context, + cinder_host=dest_host, + volume=volume) + service_locator = self._remote_api.get_service_locator_info(context, + dest_host) + host_ref = vim_util.get_moref(ds_info['host'], 'HostSystem') + rp_ref = vim_util.get_moref(ds_info['resource_pool'], 'ResourcePool') + ds_ref = vim_util.get_moref(ds_info['datastore'], 'Datastore') + + self.volumeops.relocate_backing(backing, ds_ref, rp_ref, host_ref, + profile_id=ds_info.get('profile_id'), + service=service_locator) + try: + self._remote_api.move_volume_backing_to_folder( + context, dest_host, volume, ds_info['folder']) + return (True, None) + except Exception: + # At this point the backing has been migrated to the new host. + # If this movement to folder fails, we let the manager know the + # migration happened so that it will save the new host, + # but we update its status to 'error' so that someone can check + # the logs and perform a manual action. + LOG.exception("Failed to move the backing %(volume_id)s to folder " + "%(folder)s.", + {'volume_id': volume['id'], + 'folder': ds_info['folder']},) + return (True, {'migration_status': 'error'}) + + def _migrate_attached_cross_vc(self, context, dest_host, volume, backing): + try: + # Create a diskless backing vm, so we can attach the + # backing moved in a live migration back to it + self._remote_api.create_backing( + context, dest_host, volume, create_params={ + CREATE_PARAM_DISK_LESS: True + }) + return (True, None) + except Exception: + # At this point the backing has been "migrated" to the new host. + # If this creation fails, return True so it will save the new host, + # but we update its status to 'error' so that someone can check + # the logs and perform a manual action. + LOG.exception("Failed to create the backing %(volume_id)s.", + {'volume_id': volume['id'], }, ) + return (True, {'migration_status': 'error'}) + + def update_migrated_volume(self, ctxt, volume, new_volume, + original_volume_status): + if original_volume_status == 'in-use': + # Everything should be taken care in nova + return None + + backing = self.volumeops.get_backing(new_volume['name'], + new_volume['id']) + if not backing: + LOG.warning("Backing was not found after migration.") + return None + + self.volumeops.rename_backing(backing, volume['name']) + self.volumeops.update_backing_uuid(backing, volume['id']) + self.volumeops.update_backing_disk_uuid(backing, volume['id']) + + return None diff --git a/cinder/volume/drivers/vmware/volumeops.py b/cinder/volume/drivers/vmware/volumeops.py index 7837c7eb41e..b8f68e559ee 100644 --- a/cinder/volume/drivers/vmware/volumeops.py +++ b/cinder/volume/drivers/vmware/volumeops.py @@ -36,6 +36,12 @@ FULL_CLONE_TYPE = 'full' BACKING_UUID_KEY = 'instanceUuid' +MIN_VIRTUAL_DISK_SIZE_KB = 4 * units.Ki +VM_GUEST_ID = 'otherGuest' +VM_NUM_CPUS = 1 +VM_MEMORY_MB = 128 +VMX_VERSION = 'vmx-08' +CONTROLLER_DEVICE_BUS_NUMBER = 0 def split_datastore_path(datastore_path): @@ -325,7 +331,6 @@ def get_backing_by_uuid(self, uuid): return result[0] def build_backing_ref_cache(self, name_regex=None): - LOG.debug("Building backing ref cache.") result = self._session.invoke_api( vim_util, @@ -338,8 +343,8 @@ def build_backing_ref_cache(self, name_regex=None): 'config.instanceUuid', 'config.extraConfig["cinder.volume.id"]']) - while result: - for backing in result.objects: + with vim_util.WithRetrieval(self._session.vim, result) as objects: + for backing in objects: instance_uuid = None vol_id = None @@ -359,8 +364,6 @@ def build_backing_ref_cache(self, name_regex=None): continue self._backing_ref_cache[name] = backing.obj - - result = self.continue_retrieval(result) LOG.debug("Backing ref cache size: %d.", len(self._backing_ref_cache)) def delete_backing(self, backing): @@ -375,6 +378,15 @@ def delete_backing(self, backing): self._session.wait_for_task(task) LOG.info("Deleted the VM backing: %s.", backing) + def reload_backing(self, backing): + """Reload the backing. + + :param backing: Managed object reference to the backing + """ + LOG.debug("Reloading the VM backing: %s.", backing) + self._session.invoke_api(self._session.vim, 'Reload', backing) + LOG.info("Reloaded the VM backing: %s.", backing) + # TODO(kartikaditya) Keep the methods not specific to volume in # a different file def get_host(self, instance): @@ -392,27 +404,11 @@ def get_hosts(self): :return: All the hosts from the inventory """ - return self._session.invoke_api(vim_util, 'get_objects', - self._session.vim, - 'HostSystem', self._max_objects) - - def continue_retrieval(self, retrieve_result): - """Continue retrieval of results if necessary. - - :param retrieve_result: Result from RetrievePropertiesEx - """ - - return self._session.invoke_api(vim_util, 'continue_retrieval', - self._session.vim, retrieve_result) - - def cancel_retrieval(self, retrieve_result): - """Cancel retrieval of results if necessary. - - :param retrieve_result: Result from RetrievePropertiesEx - """ - - self._session.invoke_api(vim_util, 'cancel_retrieval', - self._session.vim, retrieve_result) + result = self._session.invoke_api(vim_util, 'get_objects', + self._session.vim, + 'HostSystem', self._max_objects) + with vim_util.WithRetrieval(self._session.vim, result) as objects: + return list(objects) # TODO(vbala): move this method to datastore module def _is_usable(self, mount_info): @@ -623,6 +619,61 @@ def extend_virtual_disk(self, requested_size_in_gb, path, dc_ref, "%(size)s GB.", {'path': path, 'size': requested_size_in_gb}) + def extend_virtual_disk_online(self, requested_size_in_gb, path, vm_ref): + """Extend the virtual disk online to the requested size. + + :param requested_size_in_gb: Size of the volume in GB + :param path: Datastore path of the virtual disk to extend + :param vm_ref: Reference to the VM instance + """ + LOG.debug("Extending virtual disk: %(path)s to %(size)s GB.", + {'path': path, 'size': requested_size_in_gb}) + # VMWare API needs the capacity unit to be in KB, so convert the + # capacity unit from GB to KB. + cf = self._session.vim.client.factory + size_in_kb = requested_size_in_gb * units.Mi + config_spec = cf.create('ns0:VirtualMachineConfigSpec') + disk = None + devices = self._session.invoke_api(vim_util, + 'get_object_property', + self._session.vim, + vm_ref, + 'config.hardware.device') + for device in devices['VirtualDevice']: + if device.__class__.__name__ == "VirtualDisk" and \ + device.backing.fileName == path: + disk = device + break + else: + msg = str.format("Error during online-resize of disk: %(path)s to " + "%(size)s GB. Can't find the attachment", + {'path': path, 'size': requested_size_in_gb}) + raise exceptions.VimException(msg) + + disk.capacityInKB = size_in_kb + delattr(disk, 'capacityInBytes') + delattr(disk, 'deviceInfo') + device_change = [] + devspec = cf.create('ns0:VirtualDeviceConfigSpec') + devspec.operation = 'edit' + devspec.device = disk + device_change.append(devspec) + config_spec.deviceChange = device_change + task = self._session.invoke_api(self._session.vim, + "ReconfigVM_Task", + vm_ref, + spec=config_spec) + self._session.wait_for_task(task) + LOG.info("Successfully extended virtual disk: %(path)s to " + "%(size)s GB.", + {'path': path, 'size': requested_size_in_gb}) + + @staticmethod + def get_controller_device_shared_bus(controller_type): + if ControllerType.is_scsi_controller(controller_type): + return 'noSharing' + return None + def _create_controller_config_spec(self, adapter_type): """Returns config spec for adding a disk controller.""" cf = self._session.vim.client.factory @@ -630,23 +681,39 @@ def _create_controller_config_spec(self, adapter_type): controller_type = ControllerType.get_controller_type(adapter_type) controller_device = cf.create('ns0:%s' % controller_type) controller_device.key = -100 - controller_device.busNumber = 0 - if ControllerType.is_scsi_controller(controller_type): - controller_device.sharedBus = 'noSharing' + controller_device.busNumber = CONTROLLER_DEVICE_BUS_NUMBER + shared_bus = self.get_controller_device_shared_bus(controller_type) + if shared_bus: + controller_device.sharedBus = shared_bus controller_spec = cf.create('ns0:VirtualDeviceConfigSpec') controller_spec.operation = 'add' controller_spec.device = controller_device return controller_spec + @staticmethod + def get_disk_eagerly_scrub(disk_type): + if disk_type == VirtualDiskType.EAGER_ZEROED_THICK: + return True + return False + + @staticmethod + def get_disk_thin_provisioned(disk_type): + if disk_type == VirtualDiskType.THIN: + return True + return False + def _create_disk_backing(self, disk_type, vmdk_ds_file_path): """Creates file backing for virtual disk.""" cf = self._session.vim.client.factory disk_device_bkng = cf.create('ns0:VirtualDiskFlatVer2BackingInfo') - if disk_type == VirtualDiskType.EAGER_ZEROED_THICK: + eagerly_scrub = self.get_disk_eagerly_scrub(disk_type) + thin_provisioned = self.get_disk_thin_provisioned(disk_type) + + if eagerly_scrub: disk_device_bkng.eagerlyScrub = True - elif disk_type == VirtualDiskType.THIN: + elif thin_provisioned: disk_device_bkng.thinProvisioned = True disk_device_bkng.fileName = vmdk_ds_file_path or '' @@ -654,6 +721,16 @@ def _create_disk_backing(self, disk_type, vmdk_ds_file_path): return disk_device_bkng + @staticmethod + def get_disk_capacity_in_kb(size_kb): + return max(MIN_VIRTUAL_DISK_SIZE_KB, int(size_kb)) + + @staticmethod + def get_disk_device_key(controller_key): + if controller_key < 0: + return controller_key - 1 + return -101 + def _create_virtual_disk_config_spec(self, size_kb, disk_type, controller_key, profile_id, vmdk_ds_file_path): @@ -661,12 +738,9 @@ def _create_virtual_disk_config_spec(self, size_kb, disk_type, cf = self._session.vim.client.factory disk_device = cf.create('ns0:VirtualDisk') - # disk size should be at least 1024KB - disk_device.capacityInKB = max(units.Ki, int(size_kb)) - if controller_key < 0: - disk_device.key = controller_key - 1 - else: - disk_device.key = -101 + # disk size should be at least 4MB for VASA provider + disk_device.capacityInKB = self.get_disk_capacity_in_kb(size_kb) + disk_device.key = self.get_disk_device_key(controller_key) disk_device.unitNumber = 0 disk_device.controllerKey = controller_key disk_device.backing = self._create_disk_backing(disk_type, @@ -684,6 +758,18 @@ def _create_virtual_disk_config_spec(self, size_kb, disk_type, return disk_spec + def get_controller_key_and_spec(self, adapter_type): + controller_spec = None + if adapter_type == 'ide': + # For IDE disks, use one of the default IDE controllers (with keys + # 200 and 201) created as part of backing VM creation. + controller_key = 200 + else: + controller_spec = self._create_controller_config_spec(adapter_type) + controller_key = controller_spec.device.key + + return controller_key, controller_spec + def _create_specs_for_disk_add(self, size_kb, disk_type, adapter_type, profile_id, vmdk_ds_file_path=None): """Create controller and disk config specs for adding a new disk. @@ -697,14 +783,8 @@ def _create_specs_for_disk_add(self, size_kb, disk_type, adapter_type, not created for the virtual disk. :return: list containing controller and disk config specs """ - controller_spec = None - if adapter_type == 'ide': - # For IDE disks, use one of the default IDE controllers (with keys - # 200 and 201) created as part of backing VM creation. - controller_key = 200 - else: - controller_spec = self._create_controller_config_spec(adapter_type) - controller_key = controller_spec.device.key + (controller_key, controller_spec) = \ + self.get_controller_key_and_spec(adapter_type) disk_spec = self._create_virtual_disk_config_spec(size_kb, disk_type, @@ -736,6 +816,13 @@ def _create_managed_by_info(self): managed_by.type = self._extension_type return managed_by + @staticmethod + def get_vm_path_name(ds_name): + return '[%s]' % ds_name + + def get_vmx_version(self): + return self._vmx_version or VMX_VERSION + def _get_create_spec_disk_less(self, name, ds_name, profileId=None, extra_config=None): """Return spec for creating disk-less backing. @@ -749,19 +836,19 @@ def _get_create_spec_disk_less(self, name, ds_name, profileId=None, """ cf = self._session.vim.client.factory vm_file_info = cf.create('ns0:VirtualMachineFileInfo') - vm_file_info.vmPathName = '[%s]' % ds_name + vm_file_info.vmPathName = self.get_vm_path_name(ds_name) create_spec = cf.create('ns0:VirtualMachineConfigSpec') create_spec.name = name - create_spec.guestId = 'otherGuest' - create_spec.numCPUs = 1 - create_spec.memoryMB = 128 + create_spec.guestId = VM_GUEST_ID + create_spec.numCPUs = VM_NUM_CPUS + create_spec.memoryMB = VM_MEMORY_MB create_spec.files = vm_file_info # Set the default hardware version to a compatible version supported by # vSphere 5.0. This will ensure that the backing VM can be migrated # without any incompatibility issues in a mixed cluster of ESX hosts # with versions 5.0 or above. - create_spec.version = self._vmx_version or "vmx-08" + create_spec.version = self.get_vmx_version() if profileId: vmProfile = cf.create('ns0:VirtualMachineDefinedProfileSpec') @@ -897,19 +984,86 @@ def get_summary(self, datastore): self._session.vim, datastore, 'summary') + def get_datastore_properties(self, datastore): + """Get datastore summary. + + :param datastore: Reference to the datastore + :return: 'summary' property of the datastore + """ + return self._session.invoke_api(vim_util, 'get_object_properties_dict', + self._session.vim, datastore, + properties_to_collect=[ + "summary", + "availableField", + "customValue", + "triggeredAlarmState" + ]) + + def get_datastore_alarm(self, alarm): + return self._session.invoke_api(vim_util, 'get_object_properties_dict', + self._session.vim, alarm, + properties_to_collect=[ + 'info.name', + 'info.systemName', + 'info.description' + ]) + def _create_relocate_spec_disk_locator(self, datastore, disk_type, - disk_device): + disk_device, profile_id=None): """Creates spec for disk type conversion during relocate.""" cf = self._session.vim.client.factory disk_locator = cf.create("ns0:VirtualMachineRelocateSpecDiskLocator") disk_locator.datastore = datastore disk_locator.diskId = disk_device.key - disk_locator.diskBackingInfo = self._create_disk_backing(disk_type, - None) + + if disk_type: + disk_locator.diskBackingInfo = self._create_disk_backing(disk_type, + None) + if profile_id: + profile_spec = cf.create("ns0:VirtualMachineDefinedProfileSpec") + profile_spec.profileId = profile_id + disk_locator.profile = [profile_spec] + return disk_locator + def _get_rspec_for_one_disk(self, datastore, + disk_move_type, disk_type=None, + disk_devices=None, disk_to_move=None, + profile_id=None): + """Return spec for relocating volume backing. + + :param datastore: Reference to the datastore + :param disk_move_type: Disk move type option + :param disk_type: Destination disk type + :param disk_devices: Virtual devices corresponding to the disks + :param disk_to_move: Virtual disk, we want to move to a new ds + :param profile_id: ID of the profile to use (Cross vCenter Vmotion) + :return: Spec for relocation + """ + cf = self._session.vim.client.factory + relocate_spec = cf.create('ns0:VirtualMachineRelocateSpec') + locator = [] + for disk_device in disk_devices: + if disk_device.backing.uuid == disk_to_move.backing.uuid: + spec = self._create_relocate_spec_disk_locator(datastore, + disk_type, + disk_device, + profile_id) + else: + original_ds = disk_device.backing.datastore + spec = self._create_relocate_spec_disk_locator(original_ds, + disk_type, + disk_device, + None) + locator.append(spec) + relocate_spec.disk = locator + relocate_spec.diskMoveType = disk_move_type + LOG.debug("Spec for relocating the backing: %s.", relocate_spec) + return relocate_spec + def _get_relocate_spec(self, datastore, resource_pool, host, - disk_move_type, disk_type=None, disk_device=None): + disk_move_type, disk_type=None, disk_device=None, + profile_id=None, service=None): """Return spec for relocating volume backing. :param datastore: Reference to the datastore @@ -918,6 +1072,8 @@ def _get_relocate_spec(self, datastore, resource_pool, host, :param disk_move_type: Disk move type option :param disk_type: Destination disk type :param disk_device: Virtual device corresponding to the disk + :param profile_id: ID of the profile to use (Cross vCenter Vmotion) + :param service: Service Locator (Cross vCenter Vmotion) :return: Spec for relocation """ cf = self._session.vim.client.factory @@ -927,17 +1083,88 @@ def _get_relocate_spec(self, datastore, resource_pool, host, relocate_spec.host = host relocate_spec.diskMoveType = disk_move_type - if disk_type is not None and disk_device is not None: + # Either we want to convert the disk by specifing the disk_type + # or we need to determine the profile-id in the disk-locator + if not (disk_type is None and profile_id is None) \ + and disk_device is not None: disk_locator = self._create_relocate_spec_disk_locator(datastore, disk_type, - disk_device) + disk_device, + profile_id) relocate_spec.disk = [disk_locator] + if service is not None: + relocate_spec.service = self._get_service_locator_spec(service) + LOG.debug("Spec for relocating the backing: %s.", relocate_spec) return relocate_spec + def _get_service_locator_spec(self, service): + cf = self._session.vim.client.factory + service_locator = cf.create("ns0:ServiceLocator") + service_locator.instanceUuid = service['instance_uuid'] + service_locator.sslThumbprint = service['ssl_thumbprint'] + service_locator.url = service['url'] + + credential = cf.create("ns0:ServiceLocatorNamePassword") + credential.password = service['credential']['password'] + credential.username = service['credential']['username'] + service_locator.credential = credential + + return service_locator + + def relocate_one_disk( + self, ownervm, datastore, resource_pool, volume_id, + disk_type=None, profile_id=None): + """Relocates one disk of the consumer vm to the target datastore + + :param ownervm: Reference to the attacher of the voume + :param datastore: Reference to the datastore where we move + :param resource_pool: Reference to the resource pool + :param volume_id: ID of the cinder volume + :param disk_type: destination disk type + :param profile_id: Id of the storage profile + """ + rename_vm = self.rename_backing + # reusing existing vm rename function for the customer vm + disk_devices = self._get_disk_devices(ownervm) + disk_to_move = self.get_disk_by_uuid(ownervm, volume_id) + vmdk_path = disk_to_move.backing.fileName + disk_move_type = 'moveAllDiskBackingsAndDisallowSharing' + relocate_spec = self._get_rspec_for_one_disk(datastore, + disk_move_type, + disk_type, + disk_devices, + disk_to_move, + profile_id=profile_id) + original_name = self._session.invoke_api(vim_util, + 'get_object_property', + self._session.vim, + ownervm, 'name') + rename_vm(ownervm, volume_id) + try: + task = self._session.invoke_api(self._session.vim, + 'RelocateVM_Task', + ownervm, spec=relocate_spec) + + LOG.debug("Initiated relocation of volume main vmdk: %s.", + vmdk_path) + self._session.wait_for_task(task) + ds_val = vim_util.get_moref_value(datastore) + rp_val = vim_util.get_moref_value(resource_pool) + LOG.info("Successfully relocated volume main vmdk: %(path)s" + " to datastore: %(ds)s and resource pool: %(rp)s.", + {'path': vmdk_path, + 'ds': ds_val, 'rp': rp_val}) + except Exception as e: + LOG.error("Relocation of main vmdk: %s failed.", vmdk_path) + raise vmdk_exceptions.StorageMigrationFailed(e) + finally: + rename_vm(ownervm, original_name) + def relocate_backing( - self, backing, datastore, resource_pool, host, disk_type=None): + self, backing, datastore, resource_pool, host, disk_type=None, + profile_id=None, service=None): """Relocates backing to the input datastore and resource pool. The implementation uses moveAllDiskBackingsAndAllowSharing disk move @@ -948,6 +1175,8 @@ def relocate_backing( :param resource_pool: Reference to the resource pool :param host: Reference to the host :param disk_type: destination disk type + :param profile_id: Id of the profile (for cross vCenter) + :param service: destination service (for cross vCenter) """ LOG.debug("Relocating backing: %(backing)s to datastore: %(ds)s " "and resource pool: %(rp)s with destination disk type: " @@ -959,14 +1188,22 @@ def relocate_backing( # Relocate the volume backing disk_move_type = 'moveAllDiskBackingsAndAllowSharing' + # For migration to other vCenter service the disk_move_type needs to be + # moveAllDiskBackingsAndDisallowSharing + if service is not None: + disk_move_type = 'moveAllDiskBackingsAndDisallowSharing' + # In case of a cross-vcenter vmotion with a profile-id, + # We need to specify the profile specifically for the disk disk_device = None - if disk_type is not None: + if disk_type is not None or profile_id is not None: disk_device = self._get_disk_device(backing) relocate_spec = self._get_relocate_spec(datastore, resource_pool, host, disk_move_type, disk_type, - disk_device) + disk_device, + profile_id=profile_id, + service=service) task = self._session.invoke_api(self._session.vim, 'RelocateVM_Task', backing, spec=relocate_spec) @@ -1114,7 +1351,7 @@ def _get_folder(self, backing): def _get_clone_spec(self, datastore, disk_move_type, snapshot, backing, disk_type, host=None, resource_pool=None, - extra_config=None, disks_to_clone=None): + extra_config=None, device_changes=None): """Get the clone spec. :param datastore: Reference to datastore @@ -1126,7 +1363,7 @@ def _get_clone_spec(self, datastore, disk_move_type, snapshot, backing, :param resource_pool: Target resource pool :param extra_config: Key-value pairs to be written to backing's extra-config - :param disks_to_clone: UUIDs of disks to clone + :param device_changes: Device changes to be applied during cloning :return: Clone spec """ if disk_type is not None: @@ -1154,10 +1391,8 @@ def _get_clone_spec(self, datastore, disk_move_type, snapshot, backing, config_spec.extraConfig = self._get_extra_config_option_values( extra_config) - if disks_to_clone: - config_spec.deviceChange = ( - self._create_device_change_for_disk_removal( - backing, disks_to_clone)) + if device_changes: + config_spec.deviceChange = device_changes LOG.debug("Spec for cloning the backing: %s.", clone_spec) return clone_spec @@ -1168,13 +1403,40 @@ def _create_device_change_for_disk_removal(self, backing, disks_to_clone): device_change = [] for device in disk_devices: if device.backing.uuid not in disks_to_clone: - device_change.append(self._create_spec_for_disk_remove(device)) + spec = self._create_spec_for_device_remove(device) + device_change.append(spec) + + return device_change + + def _get_vif_devices(self, vm): + vif_devices = [] + hardware_devices = self._session.invoke_api(vim_util, + 'get_object_property', + self._session.vim, + vm, + 'config.hardware.device') + + if hardware_devices.__class__.__name__ == "ArrayOfVirtualDevice": + hardware_devices = hardware_devices.VirtualDevice + + for device in hardware_devices: + if hasattr(device, 'macAddress'): + vif_devices.append(device) + + return vif_devices + + def _create_device_change_for_vif_removal(self, backing): + devices = self._get_vif_devices(backing) + + device_change = [] + for device in devices: + device_change.append(self._create_spec_for_device_remove(device)) return device_change def clone_backing(self, name, backing, snapshot, clone_type, datastore, disk_type=None, host=None, resource_pool=None, - extra_config=None, folder=None, disks_to_clone=None): + extra_config=None, folder=None, device_changes=None): """Clone backing. If the clone_type is 'full', then a full clone of the source volume @@ -1192,7 +1454,7 @@ def clone_backing(self, name, backing, snapshot, clone_type, datastore, :param extra_config: Key-value pairs to be written to backing's extra-config :param folder: The location of the clone - :param disks_to_clone: UUIDs of disks to clone + :param device_changes: Device changes to be applied during cloning """ LOG.debug("Creating a clone of backing: %(back)s, named: %(name)s, " "clone type: %(type)s from snapshot: %(snap)s on " @@ -1213,7 +1475,7 @@ def clone_backing(self, name, backing, snapshot, clone_type, datastore, clone_spec = self._get_clone_spec( datastore, disk_move_type, snapshot, backing, disk_type, host=host, resource_pool=resource_pool, extra_config=extra_config, - disks_to_clone=disks_to_clone) + device_changes=device_changes) task = self._session.invoke_api(self._session.vim, 'CloneVM_Task', backing, folder=folder, name=name, @@ -1291,13 +1553,23 @@ def attach_disk_to_backing(self, backing, size_in_kb, disk_type, self._reconfigure_backing(backing, reconfig_spec) LOG.debug("Backing VM: %s reconfigured with new disk.", backing) - def _create_spec_for_disk_remove(self, disk_device): + def _create_spec_for_device_remove(self, disk_device): cf = self._session.vim.client.factory disk_spec = cf.create('ns0:VirtualDeviceConfigSpec') disk_spec.operation = 'remove' disk_spec.device = disk_device return disk_spec + def _create_spec_for_disk_expand(self, disk_device, new_size_in_kb): + cf = self._session.vim.client.factory + disk_spec = cf.create('ns0:VirtualDeviceConfigSpec') + disk_spec.operation = 'edit' + disk_spec.device = disk_device + disk_spec.device.capacityInKB = new_size_in_kb + disk_spec.device.capacityInBytes = ( + disk_spec.device.capacityInKB * units.Ki) + return disk_spec + def detach_disk_from_backing(self, backing, disk_device): """Detach the given disk from backing.""" @@ -1307,10 +1579,27 @@ def detach_disk_from_backing(self, backing, disk_device): cf = self._session.vim.client.factory reconfig_spec = cf.create('ns0:VirtualMachineConfigSpec') - spec = self._create_spec_for_disk_remove(disk_device) + spec = self._create_spec_for_device_remove(disk_device) reconfig_spec.deviceChange = [spec] self._reconfigure_backing(backing, reconfig_spec) + def reconfigure_backing_vmdk_path(self, backing, new_vmdk_path): + """Reconfigures backing VM with a new vmdk file pointer""" + + cf = self._session.vim.client.factory + reconfig_spec = cf.create('ns0:VirtualMachineConfigSpec') + disk_device = self._get_disk_device(backing) + disk_spec = cf.create('ns0:VirtualDeviceConfigSpec') + disk_spec.device = disk_device + disk_spec.device.backing.fileName = new_vmdk_path + disk_spec.operation = 'edit' + reconfig_spec.deviceChange = [disk_spec] + self._reconfigure_backing(backing, reconfig_spec) + LOG.debug("Backing VM: %(backing)s reconfigured with new vmdk_path: " + "%(vmdk_path)s.", + {'backing': backing, + 'vmdk_path': new_vmdk_path}) + def rename_backing(self, backing, new_name): """Rename backing VM. @@ -1675,17 +1964,16 @@ def delete_vmdk_file(self, vmdk_file_path, dc_ref): LOG.info("Deleted vmdk file: %s.", vmdk_file_path) def _get_all_clusters(self): + vim = self._session.vim clusters = {} retrieve_result = self._session.invoke_api(vim_util, 'get_objects', - self._session.vim, + vim, 'ClusterComputeResource', self._max_objects) - while retrieve_result: - if retrieve_result.objects: - for cluster in retrieve_result.objects: - name = urllib.parse.unquote(cluster.propSet[0].val) - clusters[name] = cluster.obj - retrieve_result = self.continue_retrieval(retrieve_result) + with vim_util.WithRetrieval(vim, retrieve_result) as objects: + for cluster in objects: + name = urllib.parse.unquote(cluster.propSet[0].val) + clusters[name] = cluster.obj return clusters def get_cluster_refs(self, names): @@ -1722,6 +2010,50 @@ def get_cluster_hosts(self, cluster): return host_refs + def get_cluster_custom_attributes(self, cluster, props=None): + """Retrieve custom attributes for the given cluster + + props can be a dictionary of pre-fetched properties for this cluster. + We need availableField and customValue here. If they are missing, we + fetch them. + """ + if props is None: + props = {} + + if 'availableField' not in props: + props['availableField'] = \ + self._session.invoke_api(vim_util, + 'get_object_property', + self._session.vim, + cluster, + 'availableField') + if not props['availableField']: + return + + if 'customValue' not in props: + props['customValue'] = \ + self._session.invoke_api(vim_util, + 'get_object_property', + self._session.vim, + cluster, + 'customValue') + if not props['customValue']: + return + + custom_fields = {} + for field in props['availableField']: + for v in field[1]: + custom_fields[v.key] = v.name + + custom_attributes = {} + for val in props['customValue']: + for i in val[1]: + custom_attributes[custom_fields[i.key]] = { + "value": i.value, 'id': i.key + } + + return custom_attributes + def get_entity_by_inventory_path(self, path): """Returns the managed object identified by the given inventory path. @@ -1770,30 +2102,82 @@ def get_disk_device(self, vm, vmdk_path): and backing.fileName == vmdk_path): return disk_device + def get_disk_by_uuid(self, vm, disk_uuid): + """Get the disk device of the VM which corresponds to the given uuid. + + :param vm: VM reference + :param disk_uuid: Uniq uuid of the disk, normaly same as cinder uuid + :return: Matching disk device + """ + disk_devices = self._get_disk_devices(vm) + + for disk_device in disk_devices: + backing = disk_device.backing + if (backing.__class__.__name__ == "VirtualDiskFlatVer2BackingInfo" + and backing.uuid == disk_uuid): + return disk_device + LOG.error("Virtual disk device: %s not found.", disk_uuid) + raise vmdk_exceptions.VirtualDiskNotFoundException() + def mark_backing_as_template(self, backing): LOG.debug("Marking backing: %s as template.", backing) self._session.invoke_api(self._session.vim, 'MarkAsTemplate', backing) - def _create_fcd_backing_spec(self, disk_type, ds_ref): + def _create_fcd_backing_spec(self, disk_type, ds_ref, path=None): backing_spec = self._session.vim.client.factory.create( 'ns0:VslmCreateSpecDiskFileBackingSpec') if disk_type == VirtualDiskType.PREALLOCATED: disk_type = 'lazyZeroedThick' backing_spec.provisioningType = disk_type backing_spec.datastore = ds_ref + if path: + backing_spec.path = path + '/' return backing_spec + def get_vmdk_path_for_fcd(self, ds_ref, disk_id): + vstorage_mgr = self._session.vim.service_content.vStorageObjectManager + fcd_obj = self._session.invoke_api( + self._session.vim, + 'RetrieveVStorageObject', + vstorage_mgr, + id=disk_id, + datastore=ds_ref) + vmdk_path = fcd_obj.config.backing.filePath + return vmdk_path + + def update_fcd_vmdk_uuid(self, ds_ref, vmdk_path, cinder_uuid): + def cinder_uuid_to_vmwhex(cinder_uuid): + t = iter(cinder_uuid.replace('-', '')) + hextext = ' '.join(a + b for a, b in zip(t, t)) + return hextext[:23] + '-' + hextext[24:] + + virtual_dmgr = self._session.vim.service_content.virtualDiskManager + self._session.invoke_api( + self._session.vim, + 'SetVirtualDiskUuid', + virtual_dmgr, + name=vmdk_path, + datacenter=self.get_dc(ds_ref), + uuid=cinder_uuid_to_vmwhex(cinder_uuid)) + def _create_profile_spec(self, cf, profile_id): profile_spec = cf.create('ns0:VirtualMachineDefinedProfileSpec') profile_spec.profileId = profile_id return profile_spec - def create_fcd(self, name, size_mb, ds_ref, disk_type, profile_id=None): + def create_fcd(self, cinder_uuid, name, size_mb, + ds_ref, disk_type, profile_id=None): cf = self._session.vim.client.factory spec = cf.create('ns0:VslmCreateSpec') spec.capacityInMB = size_mb spec.name = name - spec.backingSpec = self._create_fcd_backing_spec(disk_type, ds_ref) + spec.backingSpec = self._create_fcd_backing_spec(disk_type, + ds_ref, name) + dc_ref = self.get_dc(ds_ref) + ds_name = self._session.invoke_api(vim_util, 'get_object_property', + self._session.vim, ds_ref, + 'name') + self.create_datastore_folder(ds_name, name, dc_ref) if profile_id: profile_spec = self._create_profile_spec(cf, profile_id) @@ -1807,7 +2191,10 @@ def create_fcd(self, name, size_mb, ds_ref, disk_type, profile_id=None): vstorage_mgr, spec=spec) task_info = self._session.wait_for_task(task) - fcd_loc = FcdLocation.create(task_info.result.config.id, ds_ref) + fcd_obj = task_info.result + fcd_loc = FcdLocation.create(fcd_obj.config.id, ds_ref) + vmdk_path = fcd_obj.config.backing.filePath + self.update_fcd_vmdk_uuid(ds_ref, vmdk_path, cinder_uuid) LOG.debug("Created fcd: %s.", fcd_loc) return fcd_loc @@ -1823,12 +2210,19 @@ def delete_fcd(self, fcd_location): self._session.wait_for_task(task) def clone_fcd( - self, name, fcd_location, dest_ds_ref, disk_type, profile_id=None): + self, volume, fcd_location, dest_ds_ref, + disk_type, profile_id=None): cf = self._session.vim.client.factory spec = cf.create('ns0:VslmCloneSpec') - spec.name = name + spec.name = volume.name + dc_ref = self.get_dc(dest_ds_ref) + ds_name = self._session.invoke_api(vim_util, 'get_object_property', + self._session.vim, dest_ds_ref, + 'name') + self.create_datastore_folder(ds_name, volume.name, dc_ref) spec.backingSpec = self._create_fcd_backing_spec(disk_type, - dest_ds_ref) + dest_ds_ref, + volume.name) if profile_id: profile_spec = self._create_profile_spec(cf, profile_id) @@ -1847,6 +2241,8 @@ def clone_fcd( datastore=fcd_location.ds_ref(), spec=spec) task_info = self._session.wait_for_task(task) + vmdk_path = task_info.result.config.backing.filePath + self.update_fcd_vmdk_uuid(dest_ds_ref, vmdk_path, volume.id) dest_fcd_loc = FcdLocation.create(task_info.result.config.id, dest_ds_ref) LOG.debug("Clone fcd: %s.", dest_fcd_loc) @@ -1936,12 +2332,20 @@ def delete_fcd_snapshot(self, fcd_snap_loc): snapshotId=fcd_snap_loc.id(cf)) self._session.wait_for_task(task) - def create_fcd_from_snapshot(self, fcd_snap_loc, name, profile_id=None): + def create_fcd_from_snapshot(self, fcd_snap_loc, name, + cinder_uuid, profile_id=None): LOG.debug("Creating fcd with name: %(name)s from fcd snapshot: " "%(snap)s.", {'name': name, 'snap': fcd_snap_loc}) vstorage_mgr = self._session.vim.service_content.vStorageObjectManager cf = self._session.vim.client.factory + ds_ref = fcd_snap_loc.fcd_loc.ds_ref() + dc_ref = self.get_dc(ds_ref) + ds_name = self._session.invoke_api(vim_util, 'get_object_property', + self._session.vim, ds_ref, + 'name') + self.create_datastore_folder(ds_name, name, dc_ref) + if profile_id: profile = [self._create_profile_spec(cf, profile_id)] else: @@ -1954,8 +2358,12 @@ def create_fcd_from_snapshot(self, fcd_snap_loc, name, profile_id=None): datastore=fcd_snap_loc.fcd_loc.ds_ref(), snapshotId=fcd_snap_loc.id(cf), name=name, - profile=profile) + profile=profile, + path=name + '/') task_info = self._session.wait_for_task(task) + self.update_fcd_vmdk_uuid(fcd_snap_loc.fcd_loc.ds_ref(), + task_info.result.config.backing.filePath, + cinder_uuid) fcd_loc = FcdLocation.create(task_info.result.config.id, fcd_snap_loc.fcd_loc.ds_ref()) diff --git a/cinder/volume/flows/api/create_volume.py b/cinder/volume/flows/api/create_volume.py index a598fa726be..3a14238293e 100644 --- a/cinder/volume/flows/api/create_volume.py +++ b/cinder/volume/flows/api/create_volume.py @@ -21,6 +21,7 @@ from cinder.i18n import _ from cinder import objects from cinder.objects import fields +from cinder.objects import snapshot as snapshot_obj from cinder.policies import volumes as policy from cinder import quota from cinder import quota_utils @@ -761,10 +762,35 @@ def _cast_create_volume(self, context, request_spec, filter_properties): # service with the desired backend information. snapshot = objects.Snapshot.get_by_id(context, snapshot_id) request_spec['resource_backend'] = snapshot.volume.resource_backend + # SAP only force the same backend, not the same pool + # if we are allowing snapshots to live on pools other than + # the source volume. + if CONF.sap_allow_independent_snapshots: + # First see if the host was saved in metadata, because the + # source volume is allowed to be migrated off of it's original + # shard. + # If not, then use the volume's host entry. + snap_host_key = snapshot_obj.SAP_HIDDEN_BACKEND_KEY + snap_host = snapshot.metadata.get(snap_host_key) + if snap_host: + # we need to use the host entry saved in the snapshot + # metadata as the source volume can be migrated to a + # different shard. + backend = volume_utils.extract_host(snap_host) + else: + backend = volume_utils.extract_host( + snapshot.volume.resource_backend + ) + request_spec['resource_backend'] = backend elif source_volid: source_volume_ref = objects.Volume.get_by_id(context, source_volid) - request_spec['resource_backend'] = ( - source_volume_ref.resource_backend) + if CONF.sap_allow_independent_clone: + backend = volume_utils.extract_host( + source_volume_ref.resource_backend + ) + else: + backend = source_volume_ref.resource_backend + request_spec['resource_backend'] = backend self.scheduler_rpcapi.create_volume( context, diff --git a/cinder/volume/flows/manager/create_volume.py b/cinder/volume/flows/manager/create_volume.py index a5ca87336b3..f90984d85df 100644 --- a/cinder/volume/flows/manager/create_volume.py +++ b/cinder/volume/flows/manager/create_volume.py @@ -1087,6 +1087,7 @@ def _create_from_backup(self, context, volume, backup_id, **kwargs): {'id': backup_id}) model_update = self._create_raw_volume( context, volume, **kwargs) or {} + model_update['status'] = fields.VolumeStatus.RESTORING_BACKUP volume.update(model_update) volume.save() diff --git a/cinder/volume/manager.py b/cinder/volume/manager.py index 5263019181a..aea2e10d9aa 100644 --- a/cinder/volume/manager.py +++ b/cinder/volume/manager.py @@ -55,6 +55,8 @@ import requests from taskflow import exceptions as tfe +from cinder import action_track +from cinder.backup import rpcapi as backup_rpcapi from cinder.common import constants from cinder import compute from cinder import context @@ -266,6 +268,8 @@ def __init__(self, volume_driver=None, service_name: str = None, requests.packages.urllib3.exceptions.InsecurePlatformWarning) self.key_manager = key_manager.API(CONF) + # A driver can feed additional RPC endpoints into this list + driver_additional_endpoints = [] self.driver = importutils.import_object( volume_driver, configuration=self.configuration, @@ -273,7 +277,9 @@ def __init__(self, volume_driver=None, service_name: str = None, host=self.host, cluster_name=self.cluster, is_vol_db_empty=vol_db_empty, - active_backend_id=curr_active_backend_id) + active_backend_id=curr_active_backend_id, + additional_endpoints=driver_additional_endpoints) + self.additional_endpoints.extend(driver_additional_endpoints) if self.cluster and not self.driver.SUPPORTS_ACTIVE_ACTIVE: msg = _('Active-Active configuration is not currently supported ' @@ -322,6 +328,20 @@ def __init__(self, volume_driver=None, service_name: str = None, {'host': self.host}) self.image_volume_cache = None + def _update_pool_allocated_capacity(self, pool, size): + try: + pool_stat = self.stats['pools'][pool] + except KeyError: + # First volume in the pool + self.stats['pools'][pool] = dict( + allocated_capacity_gb=0) + pool_stat = self.stats['pools'][pool] + pool_sum = pool_stat['allocated_capacity_gb'] + pool_sum += size + + self.stats['pools'][pool]['allocated_capacity_gb'] = pool_sum + self.stats['allocated_capacity_gb'] += size + def _count_allocated_capacity(self, ctxt: context.RequestContext, volume: objects.Volume) -> None: pool = volume_utils.extract_host(volume['host'], 'pool') @@ -350,18 +370,29 @@ def _count_allocated_capacity(self, ctxt: context.RequestContext, pool = (self.driver.configuration.safe_get( 'volume_backend_name') or volume_utils.extract_host( volume['host'], 'pool', True)) - try: - pool_stat = self.stats['pools'][pool] - except KeyError: - # First volume in the pool - self.stats['pools'][pool] = dict( - allocated_capacity_gb=0) - pool_stat = self.stats['pools'][pool] - pool_sum = pool_stat['allocated_capacity_gb'] - pool_sum += volume['size'] - self.stats['pools'][pool]['allocated_capacity_gb'] = pool_sum - self.stats['allocated_capacity_gb'] += volume['size'] + self._update_pool_allocated_capacity(pool, volume['size']) + + def _update_snapshot_allocated_capacity(self, ctxt: context.RequestContext, + snapshot: objects.Snapshot, + host: ty.Optional[str]) -> None: + """Use the size of the snapshot to adjust the allocated capacity. + + This updates the pool stats allocated_capacity for the pool that owns + the snapshot. The snapshot lives either in it's independent pool + or in the same pool as the source volume. + + The scheduler updates the pool account at snapshot creation time. This + ensures at volume service start time, the stats are adjusted as well. + """ + size = snapshot.volume_size + if not host: + # get the source volume to find the host + volume = snapshot.volume + host = volume.host + + pool = volume_utils.extract_host(host, 'pool') + self._update_pool_allocated_capacity(pool, size) def _set_voldb_empty_at_startup_indicator( self, @@ -491,6 +522,44 @@ def _init_host(self, added_to_cluster=None, **kwargs) -> None: # Initialize backend capabilities list self.driver.init_capabilities() + # collect and count all host volumes and snapshots + volumes_to_migrate = self._count_host_stats(ctxt, export_volumes=True) + + self.driver.set_throttle() + + # at this point the driver is considered initialized. + # NOTE(jdg): Careful though because that doesn't mean + # that an entry exists in the service table + self.driver.set_initialized() + + # Keep the image tmp file clean when init host. + backend_name = volume_utils.extract_host(self.service_topic_queue) + image_utils.cleanup_temporary_file(backend_name) + + # Migrate any ConfKeyManager keys based on fixed_key to the currently + # configured key manager. + self._add_to_threadpool(key_migration.migrate_fixed_key, + volumes=volumes_to_migrate) + + # collect and publish service capabilities + self.publish_service_capabilities(ctxt) + LOG.info("Driver initialization completed successfully.", + resource={'type': 'driver', + 'id': self.driver.__class__.__name__}) + + # Make sure to call CleanableManager to do the cleanup + super(VolumeManager, self).init_host(added_to_cluster=added_to_cluster, + **kwargs) + + def recount_host_stats(self, context): + self._count_host_stats(context, export_volumes=False) + + @coordination.synchronized('volume-stats-{self.host}') + def _count_host_stats(self, context, export_volumes=False): + """Recount the number of volumes and allocated capacity.""" + ctxt = context.elevated() + LOG.info("Recounting Allocated capacity") + # Zero stats self.stats['pools'] = {} self.stats.update({'allocated_capacity_gb': 0}) @@ -519,7 +588,6 @@ def _init_host(self, added_to_cluster=None, **kwargs) -> None: req_offset: int for req_offset in req_range: - # Retrieve 'req_limit' number of objects starting from # 'req_offset' position volumes, snapshots = [], [] @@ -536,6 +604,7 @@ def _init_host(self, added_to_cluster=None, **kwargs) -> None: offset=req_offset) else: snapshots = objects.SnapshotList() + # or retrieve all volumes and snapshots per single request else: volumes = self._get_my_volumes(ctxt) @@ -546,11 +615,12 @@ def _init_host(self, added_to_cluster=None, **kwargs) -> None: try: for volume in volumes: - # available volume should also be counted into allocated - if volume['status'] in ['in-use', 'available']: + # Account for volumes that have been provisioned already + if volume['host']: # calculate allocated capacity for driver self._count_allocated_capacity(ctxt, volume) + if export_volumes: try: if volume['status'] in ['in-use']: self.driver.ensure_export(ctxt, volume) @@ -560,45 +630,38 @@ def _init_host(self, added_to_cluster=None, **kwargs) -> None: resource=volume) volume.conditional_update({'status': 'error'}, {'status': 'in-use'}) - # All other cleanups are processed by parent class - - # CleanableManager except Exception: LOG.exception("Error during re-export on driver init.", resource=volume) return + # SAP + # Account for the creation of snapshots on each pool + # this is only valid for vmware backends that are configured + # for clone based snapshots. This only counts snapshots against + # the snapshot/source volume's pool if the snapshot is a clone + # of the source volume. + if self.driver.capabilities.get('snapshot_type') == 'clone': + try: + for snapshot in snapshots: + host = None + key = objects.snapshot.SAP_HIDDEN_BACKEND_KEY + if snapshot.metadata: + host = snapshot.metadata.get(key) + self._update_snapshot_allocated_capacity( + ctxt, snapshot, host=host + ) + except Exception: + LOG.exception("Error during snapshot calculation") + if len(volumes): volumes_to_migrate.append(volumes, ctxt) del volumes del snapshots - self.driver.set_throttle() - - # at this point the driver is considered initialized. - # NOTE(jdg): Careful though because that doesn't mean - # that an entry exists in the service table - self.driver.set_initialized() - - # Keep the image tmp file clean when init host. - backend_name = volume_utils.extract_host(self.service_topic_queue) - image_utils.cleanup_temporary_file(backend_name) - - # Migrate any ConfKeyManager keys based on fixed_key to the currently - # configured key manager. - self._add_to_threadpool(key_migration.migrate_fixed_key, - volumes=volumes_to_migrate) - - # collect and publish service capabilities - self.publish_service_capabilities(ctxt) - LOG.info("Driver initialization completed successfully.", - resource={'type': 'driver', - 'id': self.driver.__class__.__name__}) - - # Make sure to call CleanableManager to do the cleanup - super(VolumeManager, self).init_host(added_to_cluster=added_to_cluster, - **kwargs) + return volumes_to_migrate def init_host_with_rpc(self) -> None: LOG.info("Initializing RPC dependent components of volume " @@ -696,6 +759,37 @@ def _set_resource_host(self, resource) -> None: resource.host = volume_utils.append_host(self.host, pool) resource.save() + def _propagate_volume_scheduler_hints(self, context, volume): + """Ensure metadata hints are propagated to referenced volumes.""" + hints = volume_utils.get_scheduler_hints_from_volume(volume) + if not hints: + return + + LOG.debug("Found hints for %(volume)s - %(hints)s", + {'volume': volume.id, 'hints': hints}) + for hint_key in hints: + for vol_id in hints[hint_key]: + try: + meta_vol = objects.Volume.get_by_id(context, vol_id) + # Because pep8 length issues + vut = volume_utils + meta_vol_hints = vut.get_scheduler_hints_from_volume( + meta_vol + ) + meta_vol_hints.setdefault(hint_key, []) + if volume.id not in meta_vol_hints[hint_key]: + meta_vol_hints[hint_key].append(volume.id) + + md = vut.set_scheduler_hints_to_volume_metadata( + meta_vol_hints, meta_vol.metadata + ) + meta_vol.metadata = md + meta_vol.save() + except Exception: + LOG.exception("Failed to set scheduler hints.", + resource=meta_vol) + + @action_track.track_decorator(action_track.ACTION_VOLUME_CREATE) @objects.Volume.set_workers def create_volume(self, context, volume, request_spec=None, filter_properties=None, @@ -804,7 +898,14 @@ def _run_flow() -> None: volume.service_uuid = self.service_uuid volume.save() - LOG.info("Created volume successfully.", resource=volume) + # propagate any scheduler hint affinity/anti-affinity metadata to + # other volumes. + self._propagate_volume_scheduler_hints(context, volume) + + action_track.track( + context, action_track.ACTION_VOLUME_CREATE, + volume, "Created Volume successfully" + ) return volume.id def _check_is_our_resource(self, resource) -> None: @@ -820,6 +921,7 @@ def _check_is_our_resource(self, resource) -> None: raise exception.Invalid(msg) @coordination.synchronized('{volume.id}-{f_name}') + @action_track.track_decorator(action_track.ACTION_VOLUME_DELETE) @objects.Volume.set_workers def delete_volume(self, context: context.RequestContext, @@ -847,7 +949,11 @@ def delete_volume(self, except exception.VolumeNotFound: # NOTE(thingee): It could be possible for a volume to # be deleted when resuming deletes from init_host(). - LOG.debug("Attempted delete of non-existent volume: %s", volume.id) + action_track.track( + context, action_track.ACTION_VOLUME_DELETE, + volume, + "Attempted delete of non-existent volume: %s" % volume.id, + ) return if context.project_id != volume.project_id: @@ -925,8 +1031,10 @@ def delete_volume(self, else: self.driver.delete_volume(volume) except exception.VolumeIsBusy: - LOG.error("Unable to delete busy volume.", - resource=volume) + action_track.track( + context, action_track.ACTION_VOLUME_DELETE, + volume, "Unable to delete busy volume.", loglevel=logging.ERROR + ) # If this is a destination volume, we have to clear the database # record to avoid user confusion. self._clear_db(is_migrating_dest, volume, 'available') @@ -981,7 +1089,10 @@ def delete_volume(self, msg = "Deleted volume successfully." if unmanage_only: msg = "Unmanaged volume successfully." - LOG.info(msg, resource=volume) + action_track.track( + context, action_track.ACTION_VOLUME_DELETE, + volume, msg + ) def _clear_db(self, is_migrating_dest, volume_ref, status) -> None: # This method is called when driver.unmanage() or @@ -1018,9 +1129,11 @@ def _revert_to_snapshot_generic(self, temp_vol = self.driver._create_temp_volume_from_snapshot( ctxt, volume, snapshot, volume_options=v_options, status=fields.VolumeStatus.IN_USE) + self._update_allocated_capacity(temp_vol) self._copy_volume_data(ctxt, temp_vol, volume) self.driver.delete_volume(temp_vol) temp_vol.destroy() + self._update_allocated_capacity(temp_vol, decrement=True) except Exception: with excutils.save_and_reraise_exception(): LOG.exception( @@ -1152,6 +1265,7 @@ def revert_to_snapshot(self, context, volume, snapshot) -> None: self._notify_about_volume_usage(context, volume, "revert.end") self._notify_about_snapshot_usage(context, snapshot, "revert.end") + @action_track.track_decorator(action_track.ACTION_SNAPSHOT_CREATE) @objects.Snapshot.set_workers def create_snapshot(self, context, snapshot) -> ovo_fields.UUIDField: """Creates and exports the snapshot.""" @@ -1205,6 +1319,11 @@ def create_snapshot(self, context, snapshot) -> ovo_fields.UUIDField: resource=snapshot) snapshot.status = fields.SnapshotStatus.ERROR snapshot.save() + action_track.track( + context, action_track.ACTION_SNAPSHOT_CREATE, + snapshot, "Failed updating snapshot metadata", + loglevel=logging.ERROR + ) self.message_api.create( context, action=message_field.Action.SNAPSHOT_CREATE, @@ -1224,11 +1343,14 @@ def create_snapshot(self, context, snapshot) -> ovo_fields.UUIDField: snapshot.save() self._notify_about_snapshot_usage(context, snapshot, "create.end") - LOG.info("Create snapshot completed successfully", - resource=snapshot) + action_track.track( + context, action_track.ACTION_SNAPSHOT_CREATE, + snapshot, "Create snapshot completed successfully", + ) return snapshot.id @coordination.synchronized('{snapshot.id}-{f_name}') + @action_track.track_decorator(action_track.ACTION_SNAPSHOT_DELETE) def delete_snapshot(self, context: context.RequestContext, snapshot: objects.Snapshot, @@ -1258,8 +1380,11 @@ def delete_snapshot(self, else: self.driver.delete_snapshot(snapshot) except exception.SnapshotIsBusy as busy_error: - LOG.error("Delete snapshot failed, due to snapshot busy.", - resource=snapshot) + action_track.track( + context, action_track.ACTION_SNAPSHOT_DELETE, + snapshot, "Delete snapshot failed, due to snapshot busy.", + loglevel=logging.ERROR + ) snapshot.status = fields.SnapshotStatus.AVAILABLE snapshot.save() self.message_api.create( @@ -1315,8 +1440,13 @@ def delete_snapshot(self, if unmanage_only: msg = "Unmanage snapshot completed successfully." LOG.info(msg, resource=snapshot) + action_track.track( + context, action_track.ACTION_SNAPSHOT_DELETE, + snapshot, msg, + ) @coordination.synchronized('{volume_id}') + @action_track.track_decorator(action_track.ACTION_VOLUME_ATTACH) def attach_volume(self, context, volume_id, instance_uuid, host_name, mountpoint, mode, volume=None) -> objects.VolumeAttachment: @@ -1380,12 +1510,15 @@ def attach_volume(self, context, volume_id, instance_uuid, host_name, # and the volume status updated. utils.require_driver_initialized(self.driver) - LOG.info('Attaching volume %(volume_id)s to instance ' - '%(instance)s at mountpoint %(mount)s on host ' - '%(host)s.', - {'volume_id': volume_id, 'instance': instance_uuid, - 'mount': mountpoint, 'host': host_name_sanitized}, - resource=volume) + msg = ('Attaching volume %(volume_id)s to instance ' + '%(instance)s at mountpoint %(mount)s on host ' + '%(host)s.' % + {'volume_id': volume_id, 'instance': instance_uuid, + 'mount': mountpoint, 'host': host_name_sanitized}) + action_track.track( + context, action_track.ACTION_VOLUME_ATTACH, + volume, msg + ) self.driver.attach_volume(context, volume, instance_uuid, @@ -1409,11 +1542,14 @@ def attach_volume(self, context, volume_id, instance_uuid, host_name, mode) self._notify_about_volume_usage(context, volume, "attach.end") - LOG.info("Attach volume completed successfully.", - resource=volume) + action_track.track( + context, action_track.ACTION_VOLUME_ATTACH, + volume, "Attach volume completed successfully.", + ) return attachment @coordination.synchronized('{volume_id}-{f_name}') + @action_track.track_decorator(action_track.ACTION_VOLUME_DETACH) def detach_volume(self, context, volume_id, attachment_id=None, volume=None) -> None: """Updates db to show volume is detached.""" @@ -1679,6 +1815,7 @@ def _clone_image_volume_and_add_location(self, ctx, volume, image_service, False) return True + @action_track.track_decorator(action_track.ACTION_VOLUME_COPY_TO_IMAGE) def copy_volume_to_image(self, context: context.RequestContext, volume_id: str, @@ -1824,6 +1961,7 @@ def _parse_connection_options(self, context, volume: objects.Volume, return conn_info + @action_track.track_decorator(action_track.ACTION_VOLUME_ATTACH) def initialize_connection(self, context, volume: objects.Volume, @@ -1908,6 +2046,9 @@ def initialize_connection(self, try: conn_info = self.driver.initialize_connection(volume, connector) + except exception.ConnectorRejected: + with excutils.save_and_reraise_exception(): + LOG.info("The connector was rejected by the volume driver.") except Exception as err: err_msg = (_("Driver initialize connection failed " "(error: %(err)s).") % {'err': err}) @@ -1983,6 +2124,7 @@ def initialize_connection_snapshot(self, resource=snapshot) return conn + @action_track.track_decorator(action_track.ACTION_VOLUME_DETACH) def terminate_connection(self, context, volume_id: ovo_fields.UUIDField, @@ -2339,7 +2481,9 @@ def _migrate_volume_generic(self, # Copy the source volume to the destination volume try: attachments = volume.volume_attachment - if not attachments: + # A volume might have attachments created, but if it is reserved + # it means it's being migrated prior to the attachment completion. + if not attachments or volume.status == 'reserved': # Pre- and post-copy driver-specific actions self.driver.before_volume_copy(ctxt, volume, new_volume, remote='dest') @@ -2577,13 +2721,15 @@ def _can_use_driver_migration(self, diff): extra_specs.pop('RESKEY:availability_zones', None) return not extra_specs + @action_track.track_decorator(action_track.ACTION_VOLUME_MIGRATE) def migrate_volume(self, ctxt: context.RequestContext, volume, host, force_host_copy: bool = False, new_type_id=None, - diff=None) -> None: + diff=None, + extend_spec=None) -> None: """Migrate the volume to the specified host (called on source host).""" try: # NOTE(flaper87): Verify the driver is enabled @@ -2597,9 +2743,10 @@ def migrate_volume(self, model_update = None moved = False + rpcapi = volume_rpcapi.VolumeAPI() status_update = None - if volume.status in ('retyping', 'maintenance'): + if volume.status in ('retyping', 'maintenance', 'extending'): status_update = {'status': volume.previous_status} volume.migration_status = 'migrating' @@ -2607,6 +2754,19 @@ def migrate_volume(self, if not force_host_copy and self._can_use_driver_migration(diff): try: LOG.debug("Issue driver.migrate_volume.", resource=volume) + # Update the remote host's allocated_capacity_gb first + # Because the migration can take a while, and the scheduler + # needs to account for the space consumed. + LOG.debug("Update remote allocated_capacity_gb for " + "host %(host)s", + {'host': host['host']}, + resource=volume) + rpcapi.update_migrated_volume_capacity(ctxt, volume, + host=host['host']) + action_track.track( + context, action_track.ACTION_VOLUME_MIGRATE, + volume, "calling driver migrate_volume" + ) moved, model_update = self.driver.migrate_volume(ctxt, volume, host) @@ -2625,9 +2785,21 @@ def migrate_volume(self, updates.update(model_update) if new_type_id: updates['volume_type_id'] = new_type_id + original_host = volume.host volume.update(updates) volume.save() + self._update_allocated_capacity(volume, decrement=True, + host=original_host) + if extend_spec: + rpcapi.extend_volume(ctxt, volume, **extend_spec) except Exception: + LOG.debug("Decrement remote allocated_capacity_gb for " + "host %(host)s", + {'host': host['host']}, + resource=volume) + rpcapi.update_migrated_volume_capacity(ctxt, volume, + host=host['host'], + decrement=True) with excutils.save_and_reraise_exception(): updates = {'migration_status': 'error'} if status_update: @@ -2636,16 +2808,43 @@ def migrate_volume(self, volume.save() if not moved: try: + original_host = volume.host + LOG.debug("Update remote allocated_capacity_gb for " + "host %(host)s", + {'host': volume.host}, + resource=volume) + rpcapi.update_migrated_volume_capacity(ctxt, volume, + host=host['host']) + action_track.track( + context, action_track.ACTION_VOLUME_MIGRATE, + volume, "Call Generic migrate volume" + ) self._migrate_volume_generic(ctxt, volume, host, new_type_id) + self._update_allocated_capacity(volume, decrement=True, + host=original_host) except Exception: + LOG.debug("Decrement remote allocated_capacity_gb for " + "host %(host)s", + {'host': host['host']}, + resource=volume) + rpcapi.update_migrated_volume_capacity(ctxt, volume, + host=host['host'], + decrement=True) with excutils.save_and_reraise_exception(): + action_track.track( + context, action_track.ACTION_VOLUME_MIGRATE, + volume, "Failed generic migration", + loglevel=logging.ERROR + ) updates = {'migration_status': 'error'} if status_update: updates.update(status_update) volume.update(updates) volume.save() - LOG.info("Migrate volume completed successfully.", - resource=volume) + action_track.track( + context, action_track.ACTION_VOLUME_MIGRATE, + volume, "Migrate volume completed successfully." + ) def _report_driver_status(self, context: context.RequestContext) -> None: # It's possible during live db migration that the self.service_uuid @@ -2691,6 +2890,11 @@ def get_stats(): if self.extra_capabilities: volume_stats.update(self.extra_capabilities) + if "pools" in volume_stats: + for pool in volume_stats["pools"]: + pool.update(self.extra_capabilities) + else: + volume_stats.update(self.extra_capabilities) if volume_stats: # NOTE(xyang): If driver reports replication_status to be @@ -2757,6 +2961,70 @@ def get_stats(): # queue it to be sent to the Schedulers. self.update_service_capabilities(volume_stats) + def _set_pool_state(self, pool): + """Check the pool stats to decide if the pool should be down. + + When a pool is overcommited already, the pool should not be + available to accept new volumes, so we mark the pool down. + """ + # Lets see if we should mark the pool as down + # if we are overcommited over the allowed amount + thin = pool.get('thin_provisioning_support', None) + thick = pool.get('thick_provisioning_support', None) + if not thin and not thick: + return + + if thin: + max_over_subscription_ratio = pool.get( + 'max_over_subscription_ratio', + float(self.configuration.max_over_subscription_ratio) + ) + thin_factors = utils.calculate_capacity_factors( + pool['total_capacity_gb'], + pool['free_capacity_gb'], + pool['allocated_capacity_gb'], + True, + float(max_over_subscription_ratio), + pool['reserved_percentage'], + True + ) + if thin_factors['virtual_free_capacity'] <= 0: + # The pool has no free space left or has been + # overcommited past what is allowed. + LOG.info("Pool(%(pool_name)s is overcommited!!", + {'pool_name': pool['pool_name']}) + pool['pool_state'] = 'down' + pool['pool_state_reason'] = ( + 'Volume manager marked pool down for being' + ' allocated beyond what is allowed for thin' + ' provisioning.' + ) + else: + # Thick provisioning won't allow max oversub + # So we force it to 1:1 + max_oversubscription_ratio = 1 + thick_factors = utils.calculate_capacity_factors( + pool['total_capacity_gb'], + pool['free_capacity_gb'], + pool['allocated_capacity_gb'], + False, + max_oversubscription_ratio, + pool['reserved_percentage'], + False + ) + if thick_factors['virtual_free_capacity'] <= 0: + # The pool has no free space left or has been + # overcommited past what is allowed. + LOG.info("Pool(%(pool_name)s is overcommited!!", + {'pool_name': pool['pool_name']}) + pool['pool_state'] = 'down' + pool['pool_state_reason'] = ( + 'Volume manager marked pool down for being' + ' allocated beyond what is allowed for thick' + ' provisioning' + ) + + @coordination.synchronized('volume-stats-{self.host}') def _append_volume_stats(self, vol_stats) -> None: pools = vol_stats.get('pools', None) if pools: @@ -2770,6 +3038,8 @@ def _append_volume_stats(self, vol_stats) -> None: pool_stats = dict(allocated_capacity_gb=0) pool.update(pool_stats) + self._set_pool_state(pool) + else: raise exception.ProgrammingError( reason='Pools stats reported by the driver are not ' @@ -2863,6 +3133,7 @@ def _notify_about_group_snapshot_usage(self, context, snapshot, event_suffix, extra_usage_info=extra_usage_info, host=self.host) + @action_track.track_decorator(action_track.ACTION_VOLUME_EXTEND) def extend_volume(self, context, volume: objects.Volume, @@ -2928,11 +3199,7 @@ def extend_volume(self, 'volume_backend_name') or volume_utils.extract_host( volume.host, 'pool', True) - try: - self.stats['pools'][pool]['allocated_capacity_gb'] += size_increase - except KeyError: - self.stats['pools'][pool] = dict( - allocated_capacity_gb=size_increase) + self._update_allocated_capacity(volume, size=size_increase) self._notify_about_volume_usage( context, volume, "resize.end", @@ -2947,6 +3214,7 @@ def _is_our_backend(self, host: str, cluster_name: str): volume_utils.hosts_are_equivalent(self.driver.cluster_name, cluster_name))) + @action_track.track_decorator(action_track.ACTION_VOLUME_RETYPE) def retype(self, context: context.RequestContext, volume: objects.Volume, @@ -3127,7 +3395,7 @@ def manage_existing(self, ctxt, volume, ref=None) -> ovo_fields.UUIDField: vol_ref = self._run_manage_existing_flow_engine( ctxt, volume, ref) - self._update_stats_for_managed(vol_ref) + self._update_allocated_capacity(vol_ref) LOG.info("Manage existing volume completed successfully.", resource=vol_ref) @@ -3613,10 +3881,12 @@ def _update_volume_from_src(self, self.db.volume_update(context, vol['id'], update) + @volume_utils.trace def _update_allocated_capacity(self, vol, decrement=False, - host: str = None) -> None: + host: str = None, + size=None) -> None: # Update allocated capacity in volume stats host = host or vol['host'] pool = volume_utils.extract_host(host, 'pool') @@ -3627,12 +3897,46 @@ def _update_allocated_capacity(self, 'pool', True) - vol_size = -vol['size'] if decrement else vol['size'] + # if a size was passed in, we use that to increment/decrement + # instead of the size in the volume. + # This is for extend + if size: + vol_size = -size if decrement else size + else: + vol_size = -vol['size'] if decrement else vol['size'] + try: - self.stats['pools'][pool]['allocated_capacity_gb'] += vol_size + curr_size = self.stats['pools'][pool]['allocated_capacity_gb'] except KeyError: self.stats['pools'][pool] = dict( - allocated_capacity_gb=max(vol_size, 0)) + allocated_capacity_gb=0) + curr_size = 0 + + msg = "Decrementing " if decrement else "Incrementing " + msg += ("allocated_capacity_gb host %(host)s (%(curr_size)s) by " + "%(vol_size)s ") + LOG.debug( + msg, + {'host': host, + 'curr_size': self.stats['pools'][pool]['allocated_capacity_gb'], + 'vol_size': vol_size}, resource=vol) + + self.stats['pools'][pool]['allocated_capacity_gb'] += vol_size + + pool_info = self.stats['pools'][pool] + if pool_info['allocated_capacity_gb'] < 0: + # Remove this once we find out why + new_size = pool_info['allocated_capacity_gb'] + LOG.warning("allocated_capacity_gb now=%(new_size)s" + " prev=%(prev_size)s " + "for pool %(pool)s is negative," + "after being altered by %(vol_size)s size. Reset to 0", + {'new_size': new_size, + 'prev_size': curr_size, + 'pool': pool, + 'vol_size': vol_size}, + resource=vol) + self.stats['pools'][pool]['allocated_capacity_gb'] = 0 def delete_group(self, context, group: objects.Group) -> None: """Deletes group and the volumes in the group.""" @@ -3747,7 +4051,7 @@ def delete_group(self, context, group: objects.Group) -> None: if reservations: QUOTAS.commit(context, reservations, project_id=project_id) - self.stats['allocated_capacity_gb'] -= vol.size + self._update_allocated_capacity(vol, decrement=True) if grpreservations: GROUP_QUOTAS.commit(context, grpreservations, @@ -4278,6 +4582,12 @@ def delete_group_snapshot(self, context, group_snapshot) -> None: "delete.end", snapshots) + @volume_utils.trace + def update_migrated_volume_capacity(self, ctxt, volume, host, + decrement=False): + """Update allocated_capacity_gb for the migrated volume host.""" + self._update_allocated_capacity(volume, host=host, decrement=decrement) + def update_migrated_volume(self, ctxt, volume, new_volume, volume_status): """Finalize migration process on backend device.""" model_update = None @@ -4644,18 +4954,43 @@ def get_capabilities(self, context, discover): LOG.debug("Obtained capabilities list: %s.", capabilities) return capabilities - def get_backup_device(self, ctxt, backup, want_objects=False): - (backup_device, is_snapshot) = ( - self.driver.get_backup_device(ctxt, backup)) + @volume_utils.trace + def get_backup_device(self, ctxt, backup, want_objects=False, + async_call=False): + try: + (backup_device, is_snapshot) = ( + self.driver.get_backup_device(ctxt, backup)) + except Exception: + if async_call: + LOG.exception("Failed to get backup device. " + "Calling backup continue_backup to cleanup") + rpcapi = backup_rpcapi.BackupAPI() + rpcapi.continue_backup(ctxt, backup, backup_device=None) + return + else: + while excutils.save_and_reraise_exception(): + LOG.exception("Failed to get backup device.") + secure_enabled = self.driver.secure_file_operations_enabled() backup_device_dict = {'backup_device': backup_device, 'secure_enabled': secure_enabled, 'is_snapshot': is_snapshot, } # TODO(sborkows): from_primitive method will be removed in O, so there # is a need to clean here then. - return (objects.BackupDeviceInfo.from_primitive(backup_device_dict, - ctxt) - if want_objects else backup_device_dict) + backup_device = ( + objects.BackupDeviceInfo.from_primitive(backup_device_dict, ctxt) + if want_objects else backup_device_dict) + + if async_call: + # we have to use an rpc call back to the backup manager to + # continue the backup + LOG.info("Calling backup continue_backup for: %s", backup) + rpcapi = backup_rpcapi.BackupAPI() + rpcapi.continue_backup(ctxt, backup, backup_device) + else: + # The rpc api version doesn't support the async callback + # so we fallback to returning the value itself. + return backup_device def secure_file_operations_enabled(self, ctxt: context.RequestContext, @@ -4671,19 +5006,33 @@ def _connection_create(self, try: self.driver.validate_connector(connector) except exception.InvalidConnectorException as err: + action_track.track( + context, action_track.ACTION_VOLUME_ATTACH, + volume, str(err), loglevel=logging.ERROR + ) raise exception.InvalidInput(reason=str(err)) except Exception as err: err_msg = (_("Validate volume connection failed " "(error: %(err)s).") % {'err': err}) - LOG.error(err_msg, resource=volume) + action_track.track( + ctxt, action_track.ACTION_VOLUME_ATTACH, + volume, err_msg, loglevel=logging.ERROR + ) raise exception.VolumeBackendAPIException(data=err_msg) try: + action_track.track( + ctxt, action_track.ACTION_VOLUME_ATTACH, + volume, "call create_export" + ) model_update = self.driver.create_export(ctxt.elevated(), volume, connector) except exception.CinderException as ex: err_msg = (_("Create export for volume failed (%s).") % ex.msg) - LOG.exception(err_msg, resource=volume) + action_track.track( + ctxt, action_track.ACTION_VOLUME_ATTACH, + volume, err_msg, loglevel=logging.ERROR + ) raise exception.VolumeBackendAPIException(data=err_msg) try: @@ -4692,15 +5041,34 @@ def _connection_create(self, volume.save() except exception.CinderException as ex: LOG.exception("Model update failed.", resource=volume) + action_track.track( + ctxt, action_track.ACTION_VOLUME_ATTACH, + volume, f"Model update failed {str(ex)}", + loglevel=logging.ERROR + ) raise exception.ExportFailure(reason=str(ex)) try: + action_track.track( + ctxt, action_track.ACTION_VOLUME_ATTACH, + volume, "call driver initialize_connection" + ) conn_info = self.driver.initialize_connection(volume, connector) + except exception.ConnectorRejected: + with excutils.save_and_reraise_exception(): + action_track.track( + ctxt, action_track.ACTION_VOLUME_ATTACH, + volume, "ConnectorRejected. Volume needs to be migrated" + ) except Exception as err: err_msg = (_("Driver initialize connection failed " "(error: %(err)s).") % {'err': err}) LOG.exception(err_msg, resource=volume) self.driver.remove_export(ctxt.elevated(), volume) + action_track.track( + ctxt, action_track.ACTION_VOLUME_ATTACH, + volume, err_msg, loglevel=logging.ERROR + ) raise exception.VolumeBackendAPIException(data=err_msg) conn_info = self._parse_connection_options(ctxt, volume, conn_info) @@ -4716,9 +5084,13 @@ def _connection_create(self, self.db.volume_attachment_update(ctxt, attachment.id, values) connection_info['attachment_id'] = attachment.id - LOG.debug("Connection info returned from driver %(connection_info)s", - {'connection_info': + msg = ("Connection info returned from driver %(connection_info)s" % + {'connection_info': strutils.mask_dict_password(connection_info)}) + action_track.track( + ctxt, action_track.ACTION_VOLUME_ATTACH, + volume, msg + ) return connection_info def attachment_update(self, @@ -4742,6 +5114,10 @@ def attachment_update(self, self._notify_about_volume_usage(context, vref, 'attach.start') attachment_ref = objects.VolumeAttachment.get_by_id(context, attachment_id) + action_track.track( + context, action_track.ACTION_VOLUME_ATTACH, + vref, "called" + ) # Check to see if a mode parameter was set during attachment-create; # this seems kinda wonky, but it's how we're keeping back compatability @@ -4759,6 +5135,10 @@ def attachment_update(self, try: utils.require_driver_initialized(self.driver) + action_track.track( + context, action_track.ACTION_VOLUME_ATTACH, + vref, "calling driver attach_volume" + ) self.driver.attach_volume(context, vref, attachment_ref.instance_uuid, @@ -4769,6 +5149,11 @@ def attachment_update(self, context, message_field.Action.UPDATE_ATTACHMENT, resource_uuid=vref.id, exception=err) + action_track.track( + context, action_track.ACTION_VOLUME_ATTACH, + vref, f"driver attach_volume failed {str(err)}", + loglevel=logging.ERROR + ) with excutils.save_and_reraise_exception(): self.db.volume_attachment_update( context, attachment_ref.id, @@ -4784,8 +5169,10 @@ def attachment_update(self, False) vref.refresh() attachment_ref.refresh() - LOG.info("attachment_update completed successfully.", - resource=vref) + action_track.track( + context, action_track.ACTION_VOLUME_ATTACH, + vref, "attachment_update completed_successfully" + ) return connection_info def _connection_terminate(self, @@ -4798,6 +5185,10 @@ def _connection_terminate(self, Exits early if the attachment does not have a connector and returns None to indicate shared connections are irrelevant. """ + action_track.track( + context, action_track.ACTION_VOLUME_DETACH, + volume, "called" + ) utils.require_driver_initialized(self.driver) connector = attachment.connector if not connector and not force: @@ -4807,11 +5198,19 @@ def _connection_terminate(self, # so if we don't have a connector we can't terminate a connection # that was never actually made to the storage backend, so just # log a message and exit. - LOG.debug('No connector for attachment %s; skipping storage ' - 'backend terminate_connection call.', attachment.id) + msg = ('No connector for attachment %s; skipping storage ' + 'backend terminate_connection call.' % attachment.id) + action_track.track( + context, action_track.ACTION_VOLUME_DETACH, + volume, msg + ) # None indicates we don't know and don't care. return None try: + action_track.track( + context, action_track.ACTION_VOLUME_DETACH, + volume, "Call driver.terminate_connection" + ) shared_connections = self.driver.terminate_connection(volume, connector, force=force) @@ -4822,9 +5221,16 @@ def _connection_terminate(self, err_msg = (_('Terminate volume connection failed: %(err)s') % {'err': err}) LOG.exception(err_msg, resource=volume) + action_track.track( + context, action_track.ACTION_VOLUME_DETACH, + volume, err_msg, loglevel=logging.ERROR + ) raise exception.VolumeBackendAPIException(data=err_msg) - LOG.info("Terminate volume connection completed successfully.", - resource=volume) + + action_track.track( + context, action_track.ACTION_VOLUME_DETACH, + volume, "Terminate volume connection completed successfully." + ) # NOTE(jdg): Return True/False if there are other outstanding # attachments that share this connection. If True should signify # caller to preserve the actual host connection (work should be @@ -4847,6 +5253,10 @@ def attachment_delete(self, NOTE if the attachment reference is None, we remove all existing attachments for the specified volume object. """ + action_track.track( + context, action_track.ACTION_VOLUME_DETACH, + vref, "called" + ) attachment_ref = objects.VolumeAttachment.get_by_id(context, attachment_id) if not attachment_ref: @@ -4865,15 +5275,24 @@ def _do_attachment_delete(self, vref, attachment) try: - LOG.debug('Deleting attachment %(attachment_id)s.', - {'attachment_id': attachment.id}, - resource=vref) + msg = ('Deleting attachment %(attachment_id)s.' % + {'attachment_id': attachment.id}) + action_track.track( + context, action_track.ACTION_VOLUME_DETACH, + vref, msg + ) self.driver.detach_volume(context, vref, attachment) if has_shared_connection is not None and not has_shared_connection: self.driver.remove_export(context.elevated(), vref) except Exception: # Failures on detach_volume and remove_export are not considered # failures in terms of detaching the volume. + action_track.track( + context, action_track.ACTION_VOLUME_DETACH, + vref, + 'Failed during driver.detach_volume or driver.remove_export', + loglevel=logging.ERROR + ) pass # Replication group API (Tiramisu) diff --git a/cinder/volume/rpcapi.py b/cinder/volume/rpcapi.py index 9b72583bca9..1d26a834dc5 100644 --- a/cinder/volume/rpcapi.py +++ b/cinder/volume/rpcapi.py @@ -134,9 +134,11 @@ class VolumeAPI(rpc.RPCAPI): failover_replication, and list_replication_targets. 3.15 - Add revert_to_snapshot method 3.16 - Add no_snapshots to accept_transfer method + 3.17 - Make get_backup_device a cast (async) + 3.17 - SAP - Added recount_host_stats (async) """ - RPC_API_VERSION = '3.16' + RPC_API_VERSION = '3.17' RPC_DEFAULT_VERSION = '3.0' TOPIC = constants.VOLUME_TOPIC BINARY = constants.VOLUME_BINARY @@ -158,6 +160,11 @@ def _get_cctxt(self, host=None, version=None, **kwargs): return super(VolumeAPI, self)._get_cctxt(version=version, **kwargs) + @rpc.assert_min_rpc_version('3.17') + def recount_host_stats(self, ctxt, host): + cctxt = self._get_cctxt(host=host) + cctxt.cast(ctxt, 'recount_host_stats') + def create_volume(self, ctxt, volume, request_spec, filter_properties, allow_reschedule=True): cctxt = self._get_cctxt(volume.service_topic_queue) @@ -259,7 +266,8 @@ def extend_volume(self, ctxt, volume, new_size, reservations): cctxt.cast(ctxt, 'extend_volume', volume=volume, new_size=new_size, reservations=reservations) - def migrate_volume(self, ctxt, volume, dest_backend, force_host_copy): + def migrate_volume(self, ctxt, volume, dest_backend, force_host_copy, + wait_for_completion=False, extend_spec=None): backend_p = {'host': dest_backend.host, 'cluster_name': dest_backend.cluster_name, 'capabilities': dest_backend.capabilities} @@ -270,8 +278,10 @@ def migrate_volume(self, ctxt, volume, dest_backend, force_host_copy): del backend_p['cluster_name'] cctxt = self._get_cctxt(volume.service_topic_queue, version) - cctxt.cast(ctxt, 'migrate_volume', volume=volume, host=backend_p, - force_host_copy=force_host_copy) + method = 'call' if wait_for_completion else 'cast' + getattr(cctxt, method)(ctxt, 'migrate_volume', volume=volume, + host=backend_p, force_host_copy=force_host_copy, + extend_spec=extend_spec) def migrate_volume_completion(self, ctxt, volume, new_volume, error): cctxt = self._get_cctxt(volume.service_topic_queue) @@ -307,6 +317,14 @@ def update_migrated_volume(self, ctxt, volume, new_volume, new_volume=new_volume, volume_status=original_volume_status) + def update_migrated_volume_capacity(self, ctxt, volume, host, + decrement=False): + cctxt = self._get_cctxt(host) + cctxt.cast(ctxt, 'update_migrated_volume_capacity', + volume=volume, + host=host, + decrement=decrement) + def freeze_host(self, ctxt, service): """Set backend host to frozen.""" cctxt = self._get_cctxt(service.service_topic_queue) @@ -344,8 +362,13 @@ def get_capabilities(self, ctxt, backend_id, discover): return cctxt.call(ctxt, 'get_capabilities', discover=discover) def get_backup_device(self, ctxt, backup, volume): - cctxt = self._get_cctxt(volume.service_topic_queue, ('3.2', '3.0')) - if cctxt.can_send_version('3.2'): + cctxt = self._get_cctxt(volume.service_topic_queue, + ('3.17', '3.2', '3.0')) + if cctxt.can_send_version('3.17'): + cctxt.cast(ctxt, 'get_backup_device', backup=backup, + want_objects=True, async_call=True) + backup_obj = None + elif cctxt.can_send_version('3.2'): backup_obj = cctxt.call(ctxt, 'get_backup_device', backup=backup, want_objects=True) else: diff --git a/cinder/volume/volume_utils.py b/cinder/volume/volume_utils.py index 588c7e99d6c..27496b2a63e 100644 --- a/cinder/volume/volume_utils.py +++ b/cinder/volume/volume_utils.py @@ -1533,3 +1533,37 @@ def setup_tracing(trace_flags): LOG.warning('Invalid trace flag: %s', invalid_flag) TRACE_METHOD = 'method' in trace_flags TRACE_API = 'api' in trace_flags + + +def get_scheduler_hints_from_volume(volume): + filter_properties = {} + if "scheduler_hint_same_host" in volume.metadata: + LOG.debug("Found a scheduler_hint_same_host in volume %s", + volume.metadata["scheduler_hint_same_host"]) + hint = volume.metadata["scheduler_hint_same_host"] + filter_properties["same_host"] = hint.split(',') + + if "scheduler_hint_different_host" in volume.metadata: + LOG.debug("Found a scheduler_hint_different_host in volume %s", + volume.metadata["scheduler_hint_different_host"]) + hint = volume.metadata["scheduler_hint_different_host"] + filter_properties["different_host"] = hint.split(',') + return filter_properties + + +def set_scheduler_hints_to_volume_metadata(scheduler_hints, + metadata): + if scheduler_hints: + if 'same_host' in scheduler_hints: + if isinstance(scheduler_hints['same_host'], str): + hint = scheduler_hints['same_host'] + else: + hint = ','.join(scheduler_hints["same_host"]) + metadata["scheduler_hint_same_host"] = hint + if "different_host" in scheduler_hints: + if isinstance(scheduler_hints['different_host'], str): + hint = scheduler_hints["different_host"] + else: + hint = ','.join(scheduler_hints["different_host"]) + metadata["scheduler_hint_different_host"] = hint + return metadata diff --git a/concourse_unit_test_task b/concourse_unit_test_task new file mode 100644 index 00000000000..c90fcadbe1e --- /dev/null +++ b/concourse_unit_test_task @@ -0,0 +1,8 @@ +export DEBIAN_FRONTEND=noninteractive && \ +apt-get update && \ +apt-get install -y build-essential python3-pip python3-dev git libpcre++-dev gettext libpq-dev && \ +pip install -U pip && \ +pip install tox && \ +cd source && \ +export TOX_CONSTRAINTS_FILE=https://raw.githubusercontent.com/sapcc/requirements/stable/wallaby-m3/upper-constraints.txt && \ +tox -e pep8,py3 diff --git a/custom-requirements.txt b/custom-requirements.txt new file mode 100644 index 00000000000..992859509f1 --- /dev/null +++ b/custom-requirements.txt @@ -0,0 +1,14 @@ +# Any custom requirements that we need for ccloud + +# needed for osprofiler +redis +jaeger-client + +-e git+https://github.com/sapcc/python-agentliveness.git#egg=agentliveness +-e git+https://github.com/sapcc/raven-python.git@ccloud#egg=raven +-e git+https://github.com/sapcc/openstack-watcher-middleware.git#egg=watcher-middleware +-e git+https://github.com/sapcc/openstack-audit-middleware.git#egg=audit-middleware +-e git+https://github.com/sapcc/openstack-rate-limit-middleware.git#egg=rate-limit-middleware +-e git+https://github.com/sapcc/os-brick.git@stable/wallaby-m3#egg=os-brick +-e git+https://github.com/sapcc/oslo.vmware.git@stable/wallaby-m3#egg=oslo.vmware +-e git+https://github.com/sapcc/dnspython.git@ccloud#egg=dnspython diff --git a/requirements.txt b/requirements.txt index 48af461c4bd..cfed27ae498 100644 --- a/requirements.txt +++ b/requirements.txt @@ -55,10 +55,11 @@ tabulate>=0.8.7 # MIT tenacity>=6.3.1 # Apache-2.0 WebOb>=1.8.6 # MIT oslo.i18n>=5.0.1 # Apache-2.0 -oslo.vmware>=3.8.0 # Apache-2.0 -os-brick>=4.3.1 # Apache-2.0 +# Double requirements with the sapcc/custom-requirements.txt +# oslo.vmware>=3.8.0 # Apache-2.0 +# os-brick>=4.3.1 # Apache-2.0 os-win>=5.4.0 # Apache-2.0 -tooz>=2.7.1 # Apache-2.0 +tooz[memcached]>=2.7.1 # Apache-2.0 google-api-python-client>=1.11.0 # Apache-2.0 castellan>=3.6.0 # Apache-2.0 cryptography>=3.1 # BSD/Apache-2.0 diff --git a/setup.cfg b/setup.cfg index d36ed0748ba..5180138ae30 100644 --- a/setup.cfg +++ b/setup.cfg @@ -45,6 +45,11 @@ cinder.scheduler.filters = RetryFilter = cinder.scheduler.filters.ignore_attempted_hosts_filter:IgnoreAttemptedHostsFilter SameBackendFilter = cinder.scheduler.filters.affinity_filter:SameBackendFilter InstanceLocalityFilter = cinder.scheduler.filters.instance_locality_filter:InstanceLocalityFilter + ShardFilter = cinder.scheduler.filters.shard_filter:ShardFilter + SAPLargeVolumeFilter = cinder.scheduler.filters.sap_large_volume_filter:SAPLargeVolumeFilter + SAPDifferentBackendFilter = cinder.scheduler.filters.sap_affinity_filter:SAPDifferentBackendFilter + SAPSameBackendFilter = cinder.scheduler.filters.sap_affinity_filter:SAPSameBackendFilter + SAPPoolDownFilter = cinder.scheduler.filters.sap_pool_down_filter:SAPPoolDownFilter cinder.scheduler.weights = AllocatedCapacityWeigher = cinder.scheduler.weights.capacity:AllocatedCapacityWeigher CapacityWeigher = cinder.scheduler.weights.capacity:CapacityWeigher diff --git a/setup.py b/setup.py index cd35c3c35bf..31d6ada1745 100644 --- a/setup.py +++ b/setup.py @@ -17,4 +17,5 @@ setuptools.setup( setup_requires=['pbr>=2.0.0'], + py_modules=[], pbr=True) diff --git a/test-requirements.txt b/test-requirements.txt index 591debf04bc..74ad39ed49f 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -23,3 +23,6 @@ bandit==1.6.0 # Apache-2.0 doc8>=0.8.1 # Apache-2.0 mypy>=0.782 # MIT moto>=1.3.15 # Apache-2.0 + +git+https://github.com/sapcc/os-brick.git@stable/wallaby-m3#egg=os-brick +git+https://github.com/sapcc/oslo.vmware.git@stable/wallaby-m3#egg=oslo.vmware \ No newline at end of file diff --git a/tools/check_exec.py b/tools/check_exec.py index 7fe59fb4310..251312935fa 100755 --- a/tools/check_exec.py +++ b/tools/check_exec.py @@ -1,4 +1,4 @@ -#!/usr/bin/python3 +#!/usr/bin/env python3 # # 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 diff --git a/tox.ini b/tox.ini index 6636861a375..2a270aedeaf 100644 --- a/tox.ini +++ b/tox.ini @@ -1,28 +1,34 @@ [tox] -minversion = 3.18.0 -requires = - virtualenv>=20.4.2 - tox<4 -skipsdist = True -# python runtimes: https://governance.openstack.org/tc/reference/project-testing-interface.html#tested-runtimes -envlist = py3,compliance,pep8 +minversion = 4.0.0 +# specify virtualenv here to keep local runs consistent with the +# gate (it sets the versions of pip, setuptools, and wheel) +requires = virtualenv>=20.17.1 # this allows tox to infer the base python from the environment name # and override any basepython configured in this file ignore_basepython_conflict=true +# python runtimes: https://governance.openstack.org/tc/reference/project-testing-interface.html#tested-runtimes +envlist = py3,compliance,pep8 [testenv] basepython = python3 -setenv = VIRTUAL_ENV={envdir} - PYTHONWARNINGS=default::DeprecationWarning - OS_STDOUT_CAPTURE=1 - OS_STDERR_CAPTURE=1 - OS_TEST_TIMEOUT=60 - OS_TEST_PATH=./cinder/tests/unit -usedevelop = True -install_command=python -m pip install -c{env:TOX_CONSTRAINTS_FILE:https://releases.openstack.org/constraints/upper/wallaby} {opts} {packages} +usedevelop = true +setenv = + VIRTUAL_ENV={envdir} + OS_STDOUT_CAPTURE=1 + OS_STDERR_CAPTURE=1 + OS_TEST_TIMEOUT=60 + OS_TEST_PATH=./cinder/tests/unit + PYTHONDONTWRITEBYTECODE=1 +# TODO(stephenfin): Remove once we bump our upper-constraint to SQLAlchemy 2.0 + SQLALCHEMY_WARN_20=1 -deps = -r{toxinidir}/test-requirements.txt - -r{toxinidir}/requirements.txt +# this environment's install command is used if the 'minversion' or 'requires' +# values declared above in the [tox] section require tox to update itself, so +# we don't define a non-default install_command here + +deps = + -r{toxinidir}/test-requirements.txt + -r{toxinidir}/requirements.txt # By default stestr will set concurrency # to ncpu, to specify something else use @@ -35,20 +41,27 @@ commands = allowlist_externals = find -passenv = *_proxy *_PROXY +passenv = + *_proxy + *_PROXY -[testenv:api-ref] -allowlist_externals = rm -deps = {[testenv:docs]deps} -commands = - rm -rf api-ref/build - sphinx-build -W -b html -d api-ref/build/doctrees api-ref/source api-ref/build/html/ - -[testenv:releasenotes] -deps = {[testenv:docs]deps} -commands = sphinx-build -a -E -W -j auto -d releasenotes/build/doctrees -b html releasenotes/source releasenotes/build/html +[testenv:py{3,36,38,39,310,311}] +# NOTE: Do not move the constraints from the install_command into deps, as that +# may result in tox using unconstrained/untested dependencies. +# We use "usedevelop = True" for tox jobs (except bindep), so tox does 2 +# install calls, one for the deps and another for the cinder source code +# as editable (pip -e). +# Without the constraints in the install_command only the first +# installation will honor the upper constraints, and the second install +# for cinder itself will not know about the constraints which can result +# in installing versions we don't want. +# With constraints in the install_command tox will always honor our +# constraints. +install_command = + python -m pip install -c{env:TOX_CONSTRAINTS_FILE:https://raw.githubusercontent.com/sapcc/requirements/stable/wallaby-m3/upper-constraints.txt} {opts} {packages} [testenv:functional] +install_command = {[testenv:py3]install_command} setenv = OS_TEST_PATH = ./cinder/tests/functional @@ -61,6 +74,7 @@ setenv = {[testenv:functional]setenv} [testenv:api-samples] +install_command = {[testenv:functional]install_command} envdir = {toxworkdir}/shared setenv = GENERATE_SAMPLES=True @@ -71,10 +85,14 @@ commands = stestr slowest [testenv:compliance] +install_command = {[testenv:py3]install_command} setenv = OS_TEST_PATH = ./cinder/tests/compliance [testenv:pep8] +allowlist_externals = + {toxinidir}/tools/config/check_uptodate.sh + {toxinidir}/tools/check_exec.py commands = flake8 {posargs} . doc8 @@ -84,11 +102,15 @@ commands = [testenv:fast8] # Use same environment directory as pep8 env to save space and install time envdir = {toxworkdir}/pep8 +install_command = {[testenv:py3]install_command} +allowlist_externals = + {toxinidir}/tools/fast8.sh commands = {toxinidir}/tools/fast8.sh passenv = FAST8_NUM_COMMITS [testenv:pylint] +install_command = {[testenv:py3]install_command} deps = -r{toxinidir}/requirements.txt -r{toxinidir}/test-requirements.txt pylint==2.6.0 @@ -98,6 +120,7 @@ commands = [testenv:cover] # Also do not run test_coverage_ext tests while gathering coverage as those # tests conflict with coverage. +install_command = {[testenv:py3]install_command} setenv = {[testenv]setenv} PYTHON=coverage run --source cinder --parallel-mode @@ -108,20 +131,27 @@ commands = coverage xml -o cover/coverage.xml [testenv:genconfig] +install_command = {[testenv:py3]install_command} sitepackages = False envdir = {toxworkdir}/pep8 commands = oslo-config-generator --config-file=tools/config/cinder-config-generator.conf [testenv:genpolicy] +install_command = {[testenv:py3]install_command} commands = oslopolicy-sample-generator --config-file=tools/config/cinder-policy-generator.conf [testenv:genopts] +install_command = {[testenv:py3]install_command} sitepackages = False envdir = {toxworkdir}/pep8 commands = python tools/config/generate_cinder_opts.py [testenv:venv] +install_command = {[testenv:py3]install_command} +deps = + {[testenv]deps} + reno commands = {posargs} [testenv:docs] @@ -134,6 +164,7 @@ commands = allowlist_externals = rm [testenv:pdf-docs] +install_command = {[testenv:docs]install_command} deps = {[testenv:docs]deps} commands = rm -fr doc/source/contributor/api/ @@ -144,12 +175,27 @@ allowlist_externals = make rm +[testenv:api-ref] +install_command = {[testenv:docs]install_command} +allowlist_externals = rm +deps = {[testenv:docs]deps} +commands = + rm -rf api-ref/build + sphinx-build -W -b html -d api-ref/build/doctrees api-ref/source api-ref/build/html/ + +[testenv:releasenotes] +install_command = {[testenv:docs]install_command} +deps = {[testenv:docs]deps} +commands = sphinx-build -a -E -W -j auto -d releasenotes/build/doctrees -b html releasenotes/source releasenotes/build/html + [testenv:gendriverlist] +install_command = {[testenv:py3]install_command} sitepackages = False envdir = {toxworkdir}/venv commands = python {toxinidir}/tools/generate_driver_list.py [testenv:bandit] +install_command = {[testenv:py3]install_command} deps = -r{toxinidir}/test-requirements.txt commands = bandit -r cinder -n5 -x cinder/tests/* -ll @@ -162,8 +208,8 @@ commands = bandit-baseline -r cinder -n5 -x cinder/tests/* -ii -ll # system dependencies are missing, since it's used to tell you what system # dependencies are missing! This also means that bindep must be installed # separately, outside of the requirements files, and develop mode disabled -# explicitly to avoid unnecessarily installing the checked-out repo too (this -# further relies on "tox.skipsdist = True" above). +# explicitly to avoid unnecessarily installing the checked-out repo too +skip_install = True deps = bindep commands = bindep {posargs} usedevelop = False