From d0285562632c329f45cc1cf65eae71f9532d3613 Mon Sep 17 00:00:00 2001 From: Zhiteng Huang Date: Sat, 18 May 2013 22:21:28 +0800 Subject: [PATCH] Implement QoS support for volumes This patch is to implement generic Quality-of-Service(QoS) support for volumes. The goal is to add an interface so that cloud/Cinder admins can use to set volume QoS, which can be enforced either in hypervisor or on Cinder back-end or both. QoS specifications are added as a standalone (only visible to admin) entity. So admin can create/update/delete and associate/disassociate QoS specifications to other entities, in this case volume types. Note that while it's possible for Cinder to set the granularity of QoS control to every single volume, this patch puts the control granularity to the level of volumes of the same type to minimize the impact of other Cinder parts. In other words, the design is to bond QoS with volume types. So Cinder admin can associate volume types with QoS specifications, and volumes of same volume type share the same QoS specifications. QoS can mean a lot different things that it's unlikely we can come up with a interpretation that all vendors can agree on. So the approach this implementation takes is to make Quality-of-Service specs as free-from, i.e. expressed as key/value pairs. Changes: - Add a quality_of_service_specs table, using adjacency list relation to store a specs entry and its detailed specs in key/values. Note that to be able to distinguish where should the QoS specs be consumed, each QoS specs entity will have a 'consumer' (i.e. fixed key) with the value of where admin would like the QoS policy to be enforced/consumed, currently these three values are considered valid: 'front-end' (Nova Compute), 'back-end' (Cinder back-end), 'both'. The default value for 'consumer' is 'back-end'; - Add a new API extension 'qos_specs_manage' to allow list/create/update/ delete/associate/disassociate of QoS specs; - Add volume/qos_specs internal API for qos specs manipulation; - Add 'qos_specs' info to data structure when initialize_connection() is called. - Add 'qos_specs' to request_specs and filter properties for a volume create request. TODO - Modify 'type_manage' API extension to be able to accept qos info. - Modify volume_types.create() to accept qos info and do the checks. DocImpact implement blueprint: pass-ratelimit-info-to-nova Change-Id: Iabc61b941aaff10395b30e2045e3421369a317e2 --- cinder/api/contrib/qos_specs_manage.py | 376 ++++++++++++++ cinder/db/api.py | 86 +++- cinder/db/sqlalchemy/api.py | 367 ++++++++++++- .../versions/018_add_qos_specs.py | 84 +++ cinder/db/sqlalchemy/models.py | 62 ++- cinder/exception.py | 41 ++ cinder/scheduler/filter_scheduler.py | 1 + .../api/contrib/test_qos_specs_manage.py | 484 ++++++++++++++++++ cinder/tests/db/test_qos_specs.py | 204 ++++++++ cinder/tests/policy.json | 3 +- cinder/tests/test_migrations.py | 43 +- cinder/tests/test_qos_specs.py | 293 +++++++++++ cinder/tests/test_volume_types.py | 20 + cinder/volume/flows/create_volume.py | 9 + cinder/volume/manager.py | 17 +- cinder/volume/qos_specs.py | 250 +++++++++ cinder/volume/volume_types.py | 7 + 17 files changed, 2327 insertions(+), 20 deletions(-) create mode 100644 cinder/api/contrib/qos_specs_manage.py create mode 100644 cinder/db/sqlalchemy/migrate_repo/versions/018_add_qos_specs.py create mode 100644 cinder/tests/api/contrib/test_qos_specs_manage.py create mode 100644 cinder/tests/db/test_qos_specs.py create mode 100644 cinder/tests/test_qos_specs.py create mode 100644 cinder/volume/qos_specs.py diff --git a/cinder/api/contrib/qos_specs_manage.py b/cinder/api/contrib/qos_specs_manage.py new file mode 100644 index 000000000..425b46d66 --- /dev/null +++ b/cinder/api/contrib/qos_specs_manage.py @@ -0,0 +1,376 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright (c) 2013 eBay Inc. +# Copyright (c) 2013 OpenStack LLC. +# +# 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 QoS specs extension""" + +import webob + +from cinder.api import extensions +from cinder.api.openstack import wsgi +from cinder.api import xmlutil +from cinder import db +from cinder import exception +from cinder.openstack.common import log as logging +from cinder.openstack.common.notifier import api as notifier_api +from cinder.volume import qos_specs +from cinder.volume import volume_types + + +LOG = logging.getLogger(__name__) + +authorize = extensions.extension_authorizer('volume', 'qos_specs_manage') + + +class QoSSpecsTemplate(xmlutil.TemplateBuilder): + def construct(self): + root = xmlutil.make_flat_dict('qos_specs', selector='qos_specs') + return xmlutil.MasterTemplate(root, 1) + + +class QoSSpecTemplate(xmlutil.TemplateBuilder): + # FIXME(zhiteng) Need to handle consumer + def construct(self): + tagname = xmlutil.Selector('key') + + def qosspec_sel(obj, do_raise=False): + # Have to extract the key and value for later use... + key, value = obj.items()[0] + return dict(key=key, value=value) + + root = xmlutil.TemplateElement(tagname, selector=qosspec_sel) + root.text = 'value' + return xmlutil.MasterTemplate(root, 1) + + +def _check_specs(context, specs_id): + try: + qos_specs.get_qos_specs(context, specs_id) + except exception.NotFound as ex: + raise webob.exc.HTTPNotFound(explanation=unicode(ex)) + + +class QoSSpecsController(wsgi.Controller): + """The volume type extra specs API controller for the OpenStack API.""" + + @staticmethod + def _notify_qos_specs_error(context, method, payload): + notifier_api.notify(context, + 'QoSSpecs', + method, + notifier_api.ERROR, + payload) + + @wsgi.serializers(xml=QoSSpecsTemplate) + def index(self, req): + """Returns the list of qos_specs.""" + context = req.environ['cinder.context'] + authorize(context) + specs = qos_specs.get_all_specs(context) + return specs + + @wsgi.serializers(xml=QoSSpecsTemplate) + def create(self, req, body=None): + context = req.environ['cinder.context'] + authorize(context) + + if not self.is_valid_body(body, 'qos_specs'): + raise webob.exc.HTTPBadRequest() + + specs = body['qos_specs'] + name = specs.get('name', None) + if name is None or name == "": + msg = _("Please specify a name for QoS specs.") + raise webob.exc.HTTPBadRequest(explanation=msg) + + try: + specs_ref = qos_specs.create(context, name, specs) + qos_specs.get_qos_specs_by_name(context, name) + notifier_info = dict(name=name, specs=specs) + notifier_api.notify(context, 'QoSSpecs', + 'QoSSpecs.create', + notifier_api.INFO, notifier_info) + except exception.InvalidInput as err: + notifier_err = dict(name=name, error_message=str(err)) + self._notify_qos_specs_error(context, + 'qos_specs.create', + notifier_err) + raise webob.exc.HTTPBadRequest(explanation=str(err)) + except exception.QoSSpecsExists as err: + notifier_err = dict(name=name, error_message=str(err)) + self._notify_qos_specs_error(context, + 'qos_specs.create', + notifier_err) + raise webob.exc.HTTPConflict(explanation=str(err)) + except exception.QoSSpecsCreateFailed as err: + notifier_err = dict(name=name, error_message=str(err)) + self._notify_qos_specs_error(context, + 'qos_specs.create', + notifier_err) + raise webob.exc.HTTPInternalServerError(explanation=str(err)) + + return body + + @wsgi.serializers(xml=QoSSpecsTemplate) + def update(self, req, id, body=None): + context = req.environ['cinder.context'] + authorize(context) + + if not self.is_valid_body(body, 'qos_specs'): + raise webob.exc.HTTPBadRequest() + specs = body['qos_specs'] + try: + qos_specs.update(context, id, specs) + notifier_info = dict(id=id, specs=specs) + notifier_api.notify(context, 'QoSSpecs', + 'qos_specs.update', + notifier_api.INFO, notifier_info) + except exception.QoSSpecsNotFound as err: + notifier_err = dict(id=id, error_message=str(err)) + self._notify_qos_specs_error(context, + 'qos_specs.update', + notifier_err) + raise webob.exc.HTTPNotFound(explanation=str(err)) + except exception.InvalidQoSSpecs as err: + notifier_err = dict(id=id, error_message=str(err)) + self._notify_qos_specs_error(context, + 'qos_specs.update', + notifier_err) + raise webob.exc.HTTPBadRequest(explanation=str(err)) + except exception.QoSSpecsUpdateFailed as err: + notifier_err = dict(id=id, error_message=str(err)) + self._notify_qos_specs_error(context, + 'qos_specs.update', + notifier_err) + raise webob.exc.HTTPInternalServerError(explanation=str(err)) + return body + + @wsgi.serializers(xml=QoSSpecsTemplate) + def show(self, req, id): + """Return a single qos spec item.""" + context = req.environ['cinder.context'] + authorize(context) + + try: + spec = qos_specs.get_qos_specs(context, id) + except exception.NotFound: + raise webob.exc.HTTPNotFound() + + return spec + + def delete(self, req, id): + """Deletes an existing qos specs.""" + context = req.environ['cinder.context'] + authorize(context) + + force = req.params.get('force', None) + + LOG.debug("qos_specs_manage.delete(): id: %s, force: %s" % (id, force)) + + try: + qos_specs.get_qos_specs(context, id) + qos_specs.delete(context, id, force) + notifier_info = dict(id=id) + notifier_api.notify(context, 'QoSSpecs', + 'qos_specs.delete', + notifier_api.INFO, notifier_info) + except exception.NotFound as err: + notifier_err = dict(id=id, error_message=str(err)) + self._notify_qos_specs_error(context, + 'qos_specs.delete', + notifier_err) + raise webob.exc.HTTPNotFound() + except exception.QoSSpecsInUse as err: + notifier_err = dict(id=id, error_message=str(err)) + self._notify_qos_specs_error(context, + 'qos_specs.delete', + notifier_err) + if force: + msg = _('Failed to disassociate qos specs.') + raise webob.exc.HTTPInternalServerError(explanation=msg) + msg = _('Qos specs still in use.') + raise webob.exc.HTTPBadRequest(explanation=msg) + + return webob.Response(status_int=202) + + @wsgi.serializers(xml=QoSSpecsTemplate) + def associations(self, req, id): + """List all associations of given qos specs.""" + context = req.environ['cinder.context'] + authorize(context) + + LOG.debug("assocications(): id: %s" % id) + + try: + associates = qos_specs.get_associations(context, id) + notifier_info = dict(id=id) + notifier_api.notify(context, 'QoSSpecs', + 'qos_specs.associations', + notifier_api.INFO, notifier_info) + except exception.QoSSpecsNotFound as err: + notifier_err = dict(id=id, error_message=str(err)) + self._notify_qos_specs_error(context, + 'qos_specs.associations', + notifier_err) + raise webob.exc.HTTPNotFound(explanation=err) + except exception.CinderException as err: + notifier_err = dict(id=id, error_message=str(err)) + self._notify_qos_specs_error(context, + 'qos_specs.associations', + notifier_err) + raise webob.exc.HTTPInternalServerError(explanation=err) + + return associates + + def associate(self, req, id): + """Associate a qos specs with a volume type.""" + context = req.environ['cinder.context'] + authorize(context) + + type_id = req.params.get('vol_type_id', None) + + if not type_id: + msg = _('Volume Type id must not be None.') + notifier_err = dict(id=id, error_message=msg) + self._notify_qos_specs_error(context, + 'qos_specs.delete', + notifier_err) + raise webob.exc.HTTPBadRequest(explanation=msg) + LOG.debug("associcate(): id: %s, type_id: %s" % (id, type_id)) + + try: + qos_specs.get_qos_specs(context, id) + qos_specs.associate_qos_with_type(context, id, type_id) + notifier_info = dict(id=id, type_id=type_id) + notifier_api.notify(context, 'QoSSpecs', + 'qos_specs.associate', + notifier_api.INFO, notifier_info) + except exception.VolumeTypeNotFound as err: + notifier_err = dict(id=id, error_message=str(err)) + self._notify_qos_specs_error(context, + 'qos_specs.associate', + notifier_err) + raise webob.exc.HTTPNotFound(explanation=err) + except exception.QoSSpecsNotFound as err: + notifier_err = dict(id=id, error_message=str(err)) + self._notify_qos_specs_error(context, + 'qos_specs.associate', + notifier_err) + raise webob.exc.HTTPNotFound(explanation=err) + except exception.QoSSpecsAssociateFailed as err: + notifier_err = dict(id=id, error_message=str(err)) + self._notify_qos_specs_error(context, + 'qos_specs.associate', + notifier_err) + raise webob.exc.HTTPInternalServerError(explanation=err) + + return webob.Response(status_int=202) + + def disassociate(self, req, id): + """Disassociate a qos specs from a volume type.""" + context = req.environ['cinder.context'] + authorize(context) + + type_id = req.params.get('vol_type_id', None) + + if not type_id: + msg = _('Volume Type id must not be None.') + notifier_err = dict(id=id, error_message=msg) + self._notify_qos_specs_error(context, + 'qos_specs.delete', + notifier_err) + raise webob.exc.HTTPBadRequest(explanation=msg) + LOG.debug("disassocicate(): id: %s, type_id: %s" % (id, type_id)) + + try: + qos_specs.get_qos_specs(context, id) + qos_specs.disassociate_qos_specs(context, id, type_id) + notifier_info = dict(id=id, type_id=type_id) + notifier_api.notify(context, 'QoSSpecs', + 'qos_specs.disassociate', + notifier_api.INFO, notifier_info) + except exception.VolumeTypeNotFound as err: + notifier_err = dict(id=id, error_message=str(err)) + self._notify_qos_specs_error(context, + 'qos_specs.disassociate', + notifier_err) + raise webob.exc.HTTPNotFound(explanation=err) + except exception.QoSSpecsNotFound as err: + notifier_err = dict(id=id, error_message=str(err)) + self._notify_qos_specs_error(context, + 'qos_specs.disassociate', + notifier_err) + raise webob.exc.HTTPNotFound(explanation=err) + except exception.QoSSpecsDisassociateFailed as err: + notifier_err = dict(id=id, error_message=str(err)) + self._notify_qos_specs_error(context, + 'qos_specs.disassociate', + notifier_err) + raise webob.exc.HTTPInternalServerError(explanation=err) + + return webob.Response(status_int=202) + + def disassociate_all(self, req, id): + """Disassociate a qos specs from all volume types.""" + context = req.environ['cinder.context'] + authorize(context) + + LOG.debug("disassocicate_all(): id: %s" % id) + + try: + qos_specs.get_qos_specs(context, id) + qos_specs.disassociate_all(context, id) + notifier_info = dict(id=id) + notifier_api.notify(context, 'QoSSpecs', + 'qos_specs.disassociate_all', + notifier_api.INFO, notifier_info) + except exception.QoSSpecsNotFound as err: + notifier_err = dict(id=id, error_message=str(err)) + self._notify_qos_specs_error(context, + 'qos_specs.disassociate_all', + notifier_err) + raise webob.exc.HTTPNotFound(explanation=err) + except exception.QoSSpecsDisassociateFailed as err: + notifier_err = dict(id=id, error_message=str(err)) + self._notify_qos_specs_error(context, + 'qos_specs.disassociate_all', + notifier_err) + raise webob.exc.HTTPInternalServerError(explanation=err) + + return webob.Response(status_int=202) + + +class Qos_specs_manage(extensions.ExtensionDescriptor): + """QoS specs support""" + + name = "Qos_specs_manage" + alias = "qos-specs" + namespace = "http://docs.openstack.org/volume/ext/qos-specs/api/v1" + updated = "2013-08-02T00:00:00+00:00" + + def get_resources(self): + resources = [] + res = extensions.ResourceExtension( + Qos_specs_manage.alias, + QoSSpecsController(), + member_actions={"associations": "GET", + "associate": "GET", + "disassociate": "GET", + "disassociate_all": "GET"}) + + resources.append(res) + + return resources diff --git a/cinder/db/api.py b/cinder/db/api.py index 54616635c..0f7d3322d 100644 --- a/cinder/db/api.py +++ b/cinder/db/api.py @@ -365,6 +365,34 @@ def volume_type_get_by_name(context, name): return IMPL.volume_type_get_by_name(context, name) +def volume_type_qos_associations_get(context, qos_specs_id, inactive=False): + """Get volume types that are associated with specific qos specs.""" + return IMPL.volume_type_qos_associations_get(context, + qos_specs_id, + inactive) + + +def volume_type_qos_associate(context, type_id, qos_specs_id): + """Associate a volume type with specific qos specs.""" + return IMPL.volume_type_qos_associate(context, type_id, qos_specs_id) + + +def volume_type_qos_disassociate(context, qos_specs_id, type_id): + """Disassociate a volume type from specific qos specs.""" + return IMPL.volume_type_qos_disassociate(context, qos_specs_id, type_id) + + +def volume_type_qos_disassociate_all(context, qos_specs_id): + """Disassociate all volume types from specific qos specs.""" + return IMPL.volume_type_qos_disassociate_all(context, + qos_specs_id) + + +def volume_type_qos_specs_get(context, type_id): + """Get all qos specs for given volume type.""" + return IMPL.volume_type_qos_specs_get(context, type_id) + + def volume_type_destroy(context, id): """Delete a volume type.""" return IMPL.volume_type_destroy(context, id) @@ -426,11 +454,65 @@ def volume_type_encryption_volume_get(context, volume_type_id, session=None): session) +def volume_encryption_metadata_get(context, volume_id, session=None): + return IMPL.volume_encryption_metadata_get(context, volume_id, session) + + ################### -def volume_encryption_metadata_get(context, volume_id, session=None): - return IMPL.volume_encryption_metadata_get(context, volume_id, session) +def qos_specs_create(context, values): + """Create a qos_specs.""" + return IMPL.qos_specs_create(context, values) + + +def qos_specs_get(context, qos_specs_id): + """Get all specification for a given qos_specs.""" + return IMPL.qos_specs_get(context, qos_specs_id) + + +def qos_specs_get_all(context, inactive=False, filters=None): + """Get all qos_specs.""" + return IMPL.qos_specs_get_all(context, inactive, filters) + + +def qos_specs_get_by_name(context, name): + """Get all specification for a given qos_specs.""" + return IMPL.qos_specs_get_by_name(context, name) + + +def qos_specs_associations_get(context, qos_specs_id): + """Get all associated volume types for a given qos_specs.""" + return IMPL.qos_specs_associations_get(context, qos_specs_id) + + +def qos_specs_associate(context, qos_specs_id, type_id): + """Associate qos_specs from volume type.""" + return IMPL.qos_specs_associate(context, qos_specs_id, type_id) + + +def qos_specs_disassociate(context, qos_specs_id, type_id): + """Disassociate qos_specs from volume type.""" + return IMPL.qos_specs_disassociate(context, qos_specs_id, type_id) + + +def qos_specs_disassociate_all(context, qos_specs_id): + """Disassociate qos_specs from all entities.""" + return IMPL.qos_specs_disassociate_all(context, qos_specs_id) + + +def qos_specs_delete(context, qos_specs_id): + """Delete the qos_specs.""" + IMPL.qos_specs_delete(context, qos_specs_id) + + +def qos_specs_update(context, qos_specs_id, specs): + """Update qos specs. + + This adds or modifies the key/value pairs specified in the + specs dict argument for a given qos_specs. + """ + IMPL.qos_specs_update(context, qos_specs_id, specs) ################### diff --git a/cinder/db/sqlalchemy/api.py b/cinder/db/sqlalchemy/api.py index 307a895fa..b6c42a667 100644 --- a/cinder/db/sqlalchemy/api.py +++ b/cinder/db/sqlalchemy/api.py @@ -28,7 +28,7 @@ import warnings from oslo.config import cfg from sqlalchemy.exc import IntegrityError from sqlalchemy import or_ -from sqlalchemy.orm import joinedload +from sqlalchemy.orm import joinedload, joinedload_all from sqlalchemy.sql.expression import literal_column from sqlalchemy.sql import func @@ -430,16 +430,15 @@ def _metadata_refs(metadata_dict, meta_class): def _dict_with_extra_specs(inst_type_query): - """Takes an instance, volume, or instance type query returned - by sqlalchemy and returns it as a dictionary, converting the - extra_specs entry from a list of dicts: + """Convert type query result to dict with extra_spec and rate_limit. - 'extra_specs' : [{'key': 'k1', 'value': 'v1', ...}, ...] + Takes a volume type query returned by sqlalchemy and returns it + as a dictionary, converting the extra_specs entry from a list + of dicts: + 'extra_specs' : [{'key': 'k1', 'value': 'v1', ...}, ...] to a single dict: - 'extra_specs' : {'k1': 'v1'} - """ inst_type_dict = dict(inst_type_query) extra_specs = dict([(x['key'], x['value']) @@ -1587,11 +1586,11 @@ def snapshot_metadata_update(context, snapshot_id, metadata, delete): @require_admin_context def volume_type_create(context, values): - """Create a new instance type. In order to pass in extra specs, - the values dict should contain a 'extra_specs' key/value pair: + """Create a new instance type. + In order to pass in extra specs, the values dict should contain a + 'extra_specs' key/value pair: {'extra_specs' : {'k1': 'v1', 'k2': 'v2', ...}} - """ if not values.get('id'): values['id'] = str(uuid.uuid4()) @@ -1633,8 +1632,6 @@ def volume_type_get_all(context, inactive=False, filters=None): order_by("name").\ all() - # TODO(sirp): this patern of converting rows to a result with extra_specs - # is repeated quite a bit, might be worth creating a method for it result = {} for row in rows: result[row['name']] = _dict_with_extra_specs(row) @@ -1686,6 +1683,88 @@ def volume_type_get_by_name(context, name): return _volume_type_get_by_name(context, name) +@require_admin_context +def volume_type_qos_associations_get(context, qos_specs_id, inactive=False): + read_deleted = "yes" if inactive else "no" + return model_query(context, models.VolumeTypes, + read_deleted=read_deleted). \ + filter_by(qos_specs_id=qos_specs_id).all() + + +@require_admin_context +def volume_type_qos_associate(context, type_id, qos_specs_id): + session = get_session() + with session.begin(): + _volume_type_get(context, type_id, session) + + session.query(models.VolumeTypes). \ + filter_by(id=type_id). \ + update({'qos_specs_id': qos_specs_id, + 'updated_at': timeutils.utcnow()}) + + +@require_admin_context +def volume_type_qos_disassociate(context, qos_specs_id, type_id): + """Disassociate volume type from qos specs.""" + session = get_session() + with session.begin(): + _volume_type_get(context, type_id, session) + + session.query(models.VolumeTypes). \ + filter_by(id=type_id). \ + filter_by(qos_specs_id=qos_specs_id). \ + update({'qos_specs_id': None, + 'updated_at': timeutils.utcnow()}) + + +@require_admin_context +def volume_type_qos_disassociate_all(context, qos_specs_id): + """Disassociate all volume types associated with specified qos specs.""" + session = get_session() + with session.begin(): + session.query(models.VolumeTypes). \ + filter_by(qos_specs_id=qos_specs_id). \ + update({'qos_specs_id': None, + 'updated_at': timeutils.utcnow()}) + + +@require_admin_context +def volume_type_qos_specs_get(context, type_id): + """Return all qos specs for given volume type. + + result looks like: + { + 'qos_specs': + { + 'id': 'qos-specs-id', + 'name': 'qos_specs_name', + 'consumer': 'Consumer', + 'key1': 'value1', + 'key2': 'value2', + 'key3': 'value3' + } + } + + """ + session = get_session() + with session.begin(): + _volume_type_get(context, type_id, session) + + row = session.query(models.VolumeTypes). \ + options(joinedload('qos_specs')). \ + filter_by(id=type_id). \ + first() + + # row.qos_specs is a list of QualityOfServiceSpecs ref + specs = {} + for item in row.qos_specs: + if item.key == 'QoS_Specs_Name': + if item.specs: + specs = _dict_with_children_specs(item.specs) + + return {'qos_specs': specs} + + @require_admin_context def volume_type_destroy(context, id): session = get_session() @@ -1799,6 +1878,270 @@ def volume_type_extra_specs_update_or_create(context, volume_type_id, #################### +@require_admin_context +def qos_specs_create(context, values): + """Create a new QoS specs. + + :param values dictionary that contains specifications for QoS + e.g. {'name': 'Name', + 'qos_specs': { + 'consumer': 'front-end', + 'total_iops_sec': 1000, + 'total_bytes_sec': 1024000 + } + } + """ + specs_id = str(uuid.uuid4()) + + session = get_session() + with session.begin(): + try: + _qos_specs_get_by_name(context, values['name'], session) + raise exception.QoSSpecsExists(specs_id=values['name']) + except exception.QoSSpecsNotFound: + pass + try: + # Insert a root entry for QoS specs + specs_root = models.QualityOfServiceSpecs() + root = dict(id=specs_id) + # 'QoS_Specs_Name' is a internal reserved key to store + # the name of QoS specs + root['key'] = 'QoS_Specs_Name' + root['value'] = values['name'] + LOG.debug("qos_specs_create(): root %s", root) + specs_root.update(root) + specs_root.save(session=session) + + # Insert all specification entries for QoS specs + for k, v in values['qos_specs'].iteritems(): + item = dict(key=k, value=v, specs_id=specs_id) + item['id'] = str(uuid.uuid4()) + spec_entry = models.QualityOfServiceSpecs() + spec_entry.update(item) + spec_entry.save(session=session) + except Exception as e: + raise db_exc.DBError(e) + + return specs_root + + +@require_admin_context +def _qos_specs_get_by_name(context, name, session=None, inactive=False): + read_deleted = 'yes' if inactive else 'no' + results = model_query(context, models.QualityOfServiceSpecs, + read_deleted=read_deleted, session=session). \ + filter_by(key='QoS_Specs_Name'). \ + filter_by(value=name). \ + options(joinedload('specs')).all() + + if not results: + raise exception.QoSSpecsNotFound(specs_id=name) + + return results + + +@require_admin_context +def _qos_specs_get_ref(context, qos_specs_id, session=None, inactive=False): + read_deleted = 'yes' if inactive else 'no' + result = model_query(context, models.QualityOfServiceSpecs, + read_deleted=read_deleted, session=session). \ + filter_by(id=qos_specs_id). \ + options(joinedload_all('specs')).all() + + if not result: + raise exception.QoSSpecsNotFound(specs_id=qos_specs_id) + + return result + + +def _dict_with_children_specs(specs): + """Convert specs list to a dict.""" + result = {} + for spec in specs: + result.update({spec['key']: spec['value']}) + + return result + + +def _dict_with_qos_specs(rows): + """Convert qos specs query results to dict with name as key. + + Qos specs query results are a list of quality_of_service_specs refs, + some are root entry of a qos specs (key == 'QoS_Specs_Name') and the + rest are children entry, a.k.a detailed specs for a qos specs. This + funtion converts query results to a dict using spec name as key. + """ + result = {} + for row in rows: + if row['key'] == 'QoS_Specs_Name': + result[row['value']] = dict(id=row['id']) + if row.specs: + spec_dict = _dict_with_children_specs(row.specs) + result[row['value']].update(spec_dict) + + return result + + +@require_admin_context +def qos_specs_get(context, qos_specs_id, inactive=False): + rows = _qos_specs_get_ref(context, qos_specs_id, None, inactive) + + return _dict_with_qos_specs(rows) + + +@require_admin_context +def qos_specs_get_all(context, inactive=False, filters=None): + """Returns dicts describing all qos_specs. + + Results is like: + {'qos-spec-1': {'id': SPECS-UUID, + 'key1': 'value1', + 'key2': 'value2', + ... + 'consumer': 'back-end'} + 'qos-spec-2': {'id': SPECS-UUID, + 'key1': 'value1', + 'key2': 'value2', + ... + 'consumer': 'back-end'} + } + """ + filters = filters or {} + #TODO(zhiteng) Add filters for 'consumer' + + read_deleted = "yes" if inactive else "no" + rows = model_query(context, models.QualityOfServiceSpecs, + read_deleted=read_deleted). \ + options(joinedload_all('specs')).all() + + return _dict_with_qos_specs(rows) + + +@require_admin_context +def qos_specs_get_by_name(context, name, inactive=False): + rows = _qos_specs_get_by_name(context, name, None, inactive) + + return _dict_with_qos_specs(rows) + + +@require_admin_context +def qos_specs_associations_get(context, qos_specs_id): + """Return all entities associated with specified qos specs. + + For now, the only entity that is possible to associate with + a qos specs is volume type, so this is just a wrapper of + volume_type_qos_associations_get(). But it's possible to + extend qos specs association to other entities, such as volumes, + sometime in future. + """ + rows = _qos_specs_get_ref(context, qos_specs_id, None) + if not rows: + raise exception.QoSSpecsNotFound(specs_id=qos_specs_id) + + return volume_type_qos_associations_get(context, qos_specs_id) + + +@require_admin_context +def qos_specs_associate(context, qos_specs_id, type_id): + """Associate volume type from specified qos specs.""" + return volume_type_qos_associate(context, type_id, qos_specs_id) + + +@require_admin_context +def qos_specs_disassociate(context, qos_specs_id, type_id): + """Disassociate volume type from specified qos specs.""" + return volume_type_qos_disassociate(context, qos_specs_id, type_id) + + +@require_admin_context +def qos_specs_disassociate_all(context, qos_specs_id): + """Disassociate all entities associated with specified qos specs. + + For now, the only entity that is possible to associate with + a qos specs is volume type, so this is just a wrapper of + volume_type_qos_disassociate_all(). But it's possible to + extend qos specs association to other entities, such as volumes, + sometime in future. + """ + return volume_type_qos_disassociate_all(context, qos_specs_id) + + +@require_admin_context +def qos_specs_item_delete(context, qos_specs_id, key): + _qos_specs_get_item(context, qos_specs_id, key) + _qos_specs_get_ref(context, qos_specs_id, None). \ + filter_by(key=key). \ + update({'deleted': True, + 'deleted_at': timeutils.utcnow(), + 'updated_at': literal_column('updated_at')}) + + +@require_admin_context +def qos_specs_delete(context, qos_specs_id): + session = get_session() + with session.begin(): + _qos_specs_get_ref(context, qos_specs_id, session) + session.query(models.QualityOfServiceSpecs).\ + filter(or_(models.QualityOfServiceSpecs.id == qos_specs_id, + models.QualityOfServiceSpecs.specs_id == + qos_specs_id)).\ + update({'deleted': True, + 'deleted_at': timeutils.utcnow(), + 'updated_at': literal_column('updated_at')}) + + +@require_admin_context +def _qos_specs_get_item(context, qos_specs_id, key, session=None): + result = model_query(context, models.QualityOfServiceSpecs, + session=session). \ + filter(models.QualityOfServiceSpecs.key == key). \ + filter(models.QualityOfServiceSpecs.specs_id == qos_specs_id). \ + first() + + if not result: + raise exception.QoSSpecsKeyNotFound( + specs_key=key, + specs_id=qos_specs_id) + + return result + + +@require_admin_context +def qos_specs_update(context, qos_specs_id, specs): + """Make updates to a existing qos specs. + + Perform add, update or delete key/values to a qos specs. + """ + + session = get_session() + with session.begin(): + # make sure qos specs exists + _qos_specs_get_ref(context, qos_specs_id, session) + spec_ref = None + for key in specs.keys(): + try: + spec_ref = _qos_specs_get_item( + context, qos_specs_id, key, session) + except exception.QoSSpecsKeyNotFound as e: + spec_ref = models.QualityOfServiceSpecs() + id = None + if spec_ref.get('id', None): + id = spec_ref['id'] + else: + id = str(uuid.uuid4()) + value = dict(id=id, key=key, value=specs[key], + specs_id=qos_specs_id, + deleted=False) + LOG.debug('qos_specs_update() value: %s' % value) + spec_ref.update(value) + spec_ref.save(session=session) + + return specs + + +#################### + + @require_context def volume_type_encryption_get(context, volume_type_id, session=None): return model_query(context, models.Encryption, session=session, diff --git a/cinder/db/sqlalchemy/migrate_repo/versions/018_add_qos_specs.py b/cinder/db/sqlalchemy/migrate_repo/versions/018_add_qos_specs.py new file mode 100644 index 000000000..7ef9546a3 --- /dev/null +++ b/cinder/db/sqlalchemy/migrate_repo/versions/018_add_qos_specs.py @@ -0,0 +1,84 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright (C) 2013 eBay Inc. +# Copyright (C) 2013 OpenStack, LLC. +# 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 sqlalchemy import Boolean, Column, DateTime +from sqlalchemy import ForeignKey, MetaData, Integer, String, Table + +from cinder.openstack.common import log as logging + +LOG = logging.getLogger(__name__) + + +def upgrade(migrate_engine): + """Add volume_type_rate_limit table.""" + meta = MetaData() + meta.bind = migrate_engine + + quality_of_service_specs = Table( + 'quality_of_service_specs', meta, + Column('created_at', DateTime(timezone=False)), + Column('updated_at', DateTime(timezone=False)), + Column('deleted_at', DateTime(timezone=False)), + Column('deleted', Boolean(create_constraint=True, name=None)), + Column('id', String(36), primary_key=True, nullable=False), + Column('specs_id', String(36), + ForeignKey('quality_of_service_specs.id')), + Column('key', String(255)), + Column('value', String(255)), + mysql_engine='InnoDB' + ) + + try: + quality_of_service_specs.create() + except Exception: + LOG.error(_("Table quality_of_service_specs not created!")) + raise + + volume_types = Table('volume_types', meta, autoload=True) + qos_specs_id = Column('qos_specs_id', String(36), + ForeignKey('quality_of_service_specs.id')) + + try: + volume_types.create_column(qos_specs_id) + volume_types.update().values(qos_specs_id=None).execute() + except Exception: + LOG.error(_("Added qos_specs_id column to volume type table failed.")) + raise + + +def downgrade(migrate_engine): + """Remove volume_type_rate_limit table.""" + meta = MetaData() + meta.bind = migrate_engine + + qos_specs = Table('quality_of_service_specs', meta, autoload=True) + + try: + qos_specs.drop() + + except Exception: + LOG.error(_("Dropping quality_of_service_specs table failed.")) + raise + + volume_types = Table('volume_types', meta, autoload=True) + qos_specs_id = Column('qos_specs_id', String(36)) + try: + volume_types.drop_column(qos_specs_id) + except Exception: + LOG.error(_("Dropping qos_specs_id column failed.")) + raise diff --git a/cinder/db/sqlalchemy/models.py b/cinder/db/sqlalchemy/models.py index 9da28cd34..013d73358 100644 --- a/cinder/db/sqlalchemy/models.py +++ b/cinder/db/sqlalchemy/models.py @@ -26,6 +26,7 @@ from sqlalchemy import Column, Integer, String, Text, schema from sqlalchemy.ext.declarative import declarative_base from sqlalchemy import ForeignKey, DateTime, Boolean from sqlalchemy.orm import relationship, backref +from sqlalchemy.orm.collections import attribute_mapped_collection from oslo.config import cfg @@ -149,7 +150,9 @@ class VolumeTypes(BASE, CinderBase): __tablename__ = "volume_types" id = Column(String(36), primary_key=True) name = Column(String(255)) - + # A reference to qos_specs entity + qos_specs_id = Column(String(36), + ForeignKey('quality_of_service_specs.id')) volumes = relationship(Volume, backref=backref('volume_type', uselist=False), foreign_keys=id, @@ -177,6 +180,63 @@ class VolumeTypeExtraSpecs(BASE, CinderBase): ) +class QualityOfServiceSpecs(BASE, CinderBase): + """Represents QoS specs as key/value pairs. + + QoS specs is standalone entity that can be associated/disassociated + with volume types (one to many relation). Adjacency list relationship + pattern is used in this model in order to represent following hierarchical + data with in flat table, e.g, following structure + + qos-specs-1 'Rate-Limit' + | + +------> consumer = 'front-end' + +------> total_bytes_sec = 1048576 + +------> total_iops_sec = 500 + + qos-specs-2 'QoS_Level1' + | + +------> consumer = 'back-end' + +------> max-iops = 1000 + +------> min-iops = 200 + + is represented by: + + id specs_id key value + ------ -------- ------------- ----- + UUID-1 NULL QoSSpec_Name Rate-Limit + UUID-2 UUID-1 consumer front-end + UUID-3 UUID-1 total_bytes_sec 1048576 + UUID-4 UUID-1 total_iops_sec 500 + UUID-5 NULL QoSSpec_Name QoS_Level1 + UUID-6 UUID-5 consumer back-end + UUID-7 UUID-5 max-iops 1000 + UUID-8 UUID-5 min-iops 200 + """ + __tablename__ = 'quality_of_service_specs' + id = Column(String(36), primary_key=True) + specs_id = Column(String(36), ForeignKey(id)) + key = Column(String(255)) + value = Column(String(255)) + + specs = relationship( + "QualityOfServiceSpecs", + cascade="all, delete-orphan", + backref=backref("qos_spec", remote_side=id), + ) + + vol_types = relationship( + VolumeTypes, + backref=backref('qos_specs'), + foreign_keys=id, + primaryjoin='and_(' + 'or_(VolumeTypes.qos_specs_id == ' + 'QualityOfServiceSpecs.id,' + 'VolumeTypes.qos_specs_id == ' + 'QualityOfServiceSpecs.specs_id),' + 'QualityOfServiceSpecs.deleted == False)') + + class VolumeGlanceMetadata(BASE, CinderBase): """Glance metadata for a bootable volume.""" __tablename__ = 'volume_glance_metadata' diff --git a/cinder/exception.py b/cinder/exception.py index 1d6141345..8939e68fc 100644 --- a/cinder/exception.py +++ b/cinder/exception.py @@ -657,3 +657,44 @@ class CoraidESMConfigureError(CoraidException): class CoraidESMNotAvailable(CoraidException): message = _('Coraid ESM not available with reason: %(reason)s.') + + +class QoSSpecsExists(Duplicate): + message = _("QoS Specs %(specs_id)s already exists.") + + +class QoSSpecsCreateFailed(CinderException): + message = _("Failed to create qos_specs: " + "%(name)s with specs %(qos_specs)s.") + + +class QoSSpecsUpdateFailed(CinderException): + message = _("Failed to update qos_specs: " + "%(specs_id)s with specs %(qos_specs)s.") + + +class QoSSpecsNotFound(NotFound): + message = _("No such QoS spec %(specs_id)s.") + + +class QoSSpecsAssociateFailed(CinderException): + message = _("Failed to associate qos_specs: " + "%(specs_id)s with type %(type_id)s.") + + +class QoSSpecsDisassociateFailed(CinderException): + message = _("Failed to disassociate qos_specs: " + "%(specs_id)s with type %(type_id)s.") + + +class QoSSpecsKeyNotFound(NotFound): + message = _("QoS spec %(specs_id)s has no spec with " + "key %(specs_key)s.") + + +class InvalidQoSSpecs(Invalid): + message = _("Invalid qos specs") + ": %(reason)s" + + +class QoSSpecsInUse(CinderException): + message = _("QoS Specs %(specs_id)s is still associated with entities.") diff --git a/cinder/scheduler/filter_scheduler.py b/cinder/scheduler/filter_scheduler.py index 83a135ef7..ef813f095 100644 --- a/cinder/scheduler/filter_scheduler.py +++ b/cinder/scheduler/filter_scheduler.py @@ -58,6 +58,7 @@ class FilterScheduler(driver.Scheduler): filter_properties['availability_zone'] = vol.get('availability_zone') filter_properties['user_id'] = vol.get('user_id') filter_properties['metadata'] = vol.get('metadata') + filter_properties['qos_specs'] = vol.get('qos_specs') def schedule_create_volume(self, context, request_spec, filter_properties): weighed_host = self._schedule(context, request_spec, diff --git a/cinder/tests/api/contrib/test_qos_specs_manage.py b/cinder/tests/api/contrib/test_qos_specs_manage.py new file mode 100644 index 000000000..0bf8e588d --- /dev/null +++ b/cinder/tests/api/contrib/test_qos_specs_manage.py @@ -0,0 +1,484 @@ +# Copyright 2013 eBay Inc. +# Copyright 2013 OpenStack LLC. +# 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 webob + +from cinder.api.contrib import qos_specs_manage +from cinder import exception +from cinder.openstack.common.notifier import api as notifier_api +from cinder.openstack.common.notifier import test_notifier +from cinder import test +from cinder.tests.api import fakes +from cinder.volume import qos_specs + + +def stub_qos_specs(id): + specs = {"key1": "value1", + "key2": "value2", + "key3": "value3", + "key4": "value4", + "key5": "value5"} + specs.update(dict(id=str(id))) + return specs + + +def stub_qos_associates(id): + return {str(id): {'FakeVolTypeName': 'FakeVolTypeID'}} + + +def return_qos_specs_get_all(context): + return dict( + qos_specs_1=stub_qos_specs(1), + qos_specs_2=stub_qos_specs(2), + qos_specs_3=stub_qos_specs(3) + ) + + +def return_qos_specs_get_qos_specs(context, id): + if id == "777": + raise exception.QoSSpecsNotFound(specs_id=id) + name = 'qos_specs_%s' % id + return {name: stub_qos_specs(int(id))} + + +def return_qos_specs_delete(context, id, force): + if id == "777": + raise exception.QoSSpecsNotFound(specs_id=id) + elif id == "666": + raise exception.QoSSpecsInUse(specs_id=id) + pass + + +def return_qos_specs_update(context, id, specs): + if id == "777": + raise exception.QoSSpecsNotFound(specs_id=id) + elif id == "888": + raise exception.InvalidQoSSpecs(reason=str(id)) + elif id == "999": + raise exception.QoSSpecsUpdateFailed(specs_id=id, + qos_specs=specs) + pass + + +def return_qos_specs_create(context, name, specs): + if name == "666": + raise exception.QoSSpecsExists(specs_id=name) + elif name == "555": + raise exception.QoSSpecsCreateFailed(name=id, qos_specs=specs) + pass + + +def return_qos_specs_get_by_name(context, name): + if name == "777": + raise exception.QoSSpecsNotFound(specs_id=name) + + return stub_qos_specs(int(name.split("_")[2])) + + +def return_get_qos_associations(context, id): + if id == "111": + raise exception.QoSSpecsNotFound(specs_id=id) + elif id == "222": + raise exception.CinderException() + + return stub_qos_associates(id) + + +def return_associate_qos_specs(context, id, type_id): + if id == "111": + raise exception.QoSSpecsNotFound(specs_id=id) + elif id == "222": + raise exception.QoSSpecsAssociateFailed(specs_id=id, + type_id=type_id) + elif id == "333": + raise exception.QoSSpecsDisassociateFailed(specs_id=id, + type_id=type_id) + + if type_id == "1234": + raise exception.VolumeTypeNotFound( + volume_type_id=type_id) + + pass + + +def return_disassociate_all(context, id): + if id == "111": + raise exception.QoSSpecsNotFound(specs_id=id) + elif id == "222": + raise exception.QoSSpecsDisassociateFailed(specs_id=id, + type_id=None) + + +class QoSSpecManageApiTest(test.TestCase): + def setUp(self): + super(QoSSpecManageApiTest, self).setUp() + self.flags(connection_type='fake', + host='fake', + notification_driver=[test_notifier.__name__]) + self.controller = qos_specs_manage.QoSSpecsController() + """to reset notifier drivers left over from other api/contrib tests""" + notifier_api._reset_drivers() + test_notifier.NOTIFICATIONS = [] + + def tearDown(self): + notifier_api._reset_drivers() + super(QoSSpecManageApiTest, self).tearDown() + + def test_index(self): + self.stubs.Set(qos_specs, 'get_all_specs', + return_qos_specs_get_all) + + req = fakes.HTTPRequest.blank('/v2/fake/qos-specs') + res_dict = self.controller.index(req) + + self.assertEqual(3, len(res_dict.keys())) + + expected_names = ['qos_specs_1', 'qos_specs_2', 'qos_specs_3'] + self.assertEqual(set(res_dict.keys()), set(expected_names)) + for key in res_dict.keys(): + self.assertEqual('value1', res_dict[key]['key1']) + + def test_qos_specs_delete(self): + self.stubs.Set(qos_specs, 'get_qos_specs', + return_qos_specs_get_qos_specs) + self.stubs.Set(qos_specs, 'delete', + return_qos_specs_delete) + req = fakes.HTTPRequest.blank('/v2/fake/qos-specs/1') + self.assertEquals(len(test_notifier.NOTIFICATIONS), 0) + self.controller.delete(req, 1) + self.assertEquals(len(test_notifier.NOTIFICATIONS), 1) + + def test_qos_specs_delete_not_found(self): + self.stubs.Set(qos_specs, 'get_qos_specs', + return_qos_specs_get_qos_specs) + self.stubs.Set(qos_specs, 'delete', + return_qos_specs_delete) + + self.assertEquals(len(test_notifier.NOTIFICATIONS), 0) + req = fakes.HTTPRequest.blank('/v2/fake/qos-specs/777') + self.assertRaises(webob.exc.HTTPNotFound, self.controller.delete, + req, '777') + self.assertEquals(len(test_notifier.NOTIFICATIONS), 1) + + def test_qos_specs_delete_inuse(self): + self.stubs.Set(qos_specs, 'get_qos_specs', + return_qos_specs_get_qos_specs) + self.stubs.Set(qos_specs, 'delete', + return_qos_specs_delete) + + req = fakes.HTTPRequest.blank('/v2/fake/qos-specs/666') + self.assertEquals(len(test_notifier.NOTIFICATIONS), 0) + self.assertRaises(webob.exc.HTTPBadRequest, self.controller.delete, + req, '666') + self.assertEquals(len(test_notifier.NOTIFICATIONS), 1) + + def test_qos_specs_delete_inuse_force(self): + self.stubs.Set(qos_specs, 'get_qos_specs', + return_qos_specs_get_qos_specs) + self.stubs.Set(qos_specs, 'delete', + return_qos_specs_delete) + + req = fakes.HTTPRequest.blank('/v2/fake/qos-specs/666?force=True') + self.assertEquals(len(test_notifier.NOTIFICATIONS), 0) + self.assertRaises(webob.exc.HTTPInternalServerError, + self.controller.delete, + req, '666') + self.assertEquals(len(test_notifier.NOTIFICATIONS), 1) + + def test_create(self): + self.stubs.Set(qos_specs, 'create', + return_qos_specs_create) + self.stubs.Set(qos_specs, 'get_qos_specs_by_name', + return_qos_specs_get_by_name) + + body = {"qos_specs": {"name": "qos_specs_1", + "key1": "value1"}} + req = fakes.HTTPRequest.blank('/v2/fake/qos-specs') + + self.assertEquals(len(test_notifier.NOTIFICATIONS), 0) + res_dict = self.controller.create(req, body) + + self.assertEquals(len(test_notifier.NOTIFICATIONS), 1) + self.assertEqual(1, len(res_dict)) + self.assertEqual('qos_specs_1', res_dict['qos_specs']['name']) + + def test_create_conflict(self): + self.stubs.Set(qos_specs, 'create', + return_qos_specs_create) + self.stubs.Set(qos_specs, 'get_qos_specs_by_name', + return_qos_specs_get_by_name) + + body = {"qos_specs": {"name": "666", + "key1": "value1"}} + req = fakes.HTTPRequest.blank('/v2/fake/qos-specs') + + self.assertEquals(len(test_notifier.NOTIFICATIONS), 0) + self.assertRaises(webob.exc.HTTPConflict, + self.controller.create, req, body) + self.assertEquals(len(test_notifier.NOTIFICATIONS), 1) + + def test_create_failed(self): + self.stubs.Set(qos_specs, 'create', + return_qos_specs_create) + self.stubs.Set(qos_specs, 'get_qos_specs_by_name', + return_qos_specs_get_by_name) + + body = {"qos_specs": {"name": "555", + "key1": "value1"}} + req = fakes.HTTPRequest.blank('/v2/fake/qos-specs') + + self.assertEquals(len(test_notifier.NOTIFICATIONS), 0) + self.assertRaises(webob.exc.HTTPInternalServerError, + self.controller.create, req, body) + self.assertEquals(len(test_notifier.NOTIFICATIONS), 1) + + def _create_qos_specs_bad_body(self, body): + req = fakes.HTTPRequest.blank('/v2/fake/qos-specs') + req.method = 'POST' + self.assertRaises(webob.exc.HTTPBadRequest, + self.controller.create, req, body) + + def test_create_no_body(self): + self._create_qos_specs_bad_body(body=None) + + def test_create_missing_specs_name(self): + body = {'foo': {'a': 'b'}} + self._create_qos_specs_bad_body(body=body) + + def test_create_malformed_entity(self): + body = {'qos_specs': 'string'} + self._create_qos_specs_bad_body(body=body) + + def test_update(self): + self.stubs.Set(qos_specs, 'update', + return_qos_specs_update) + + self.assertEquals(len(test_notifier.NOTIFICATIONS), 0) + req = fakes.HTTPRequest.blank('/v2/fake/qos-specs/555') + body = {'qos_specs': {'key1': 'value1', + 'key2': 'value2'}} + res = self.controller.update(req, '555', body) + self.assertDictMatch(res, body) + self.assertEquals(len(test_notifier.NOTIFICATIONS), 1) + + def test_update_not_found(self): + self.stubs.Set(qos_specs, 'update', + return_qos_specs_update) + + self.assertEquals(len(test_notifier.NOTIFICATIONS), 0) + req = fakes.HTTPRequest.blank('/v2/fake/qos-specs/777') + body = {'qos_specs': {'key1': 'value1', + 'key2': 'value2'}} + self.assertRaises(webob.exc.HTTPNotFound, self.controller.update, + req, '777', body) + self.assertEquals(len(test_notifier.NOTIFICATIONS), 1) + + def test_update_invalid_input(self): + self.stubs.Set(qos_specs, 'update', + return_qos_specs_update) + + self.assertEquals(len(test_notifier.NOTIFICATIONS), 0) + req = fakes.HTTPRequest.blank('/v2/fake/qos-specs/888') + body = {'qos_specs': {'key1': 'value1', + 'key2': 'value2'}} + self.assertRaises(webob.exc.HTTPBadRequest, self.controller.update, + req, '888', body) + self.assertEquals(len(test_notifier.NOTIFICATIONS), 1) + + def test_update_failed(self): + self.stubs.Set(qos_specs, 'update', + return_qos_specs_update) + + self.assertEquals(len(test_notifier.NOTIFICATIONS), 0) + req = fakes.HTTPRequest.blank('/v2/fake/qos-specs/999') + body = {'qos_specs': {'key1': 'value1', + 'key2': 'value2'}} + self.assertRaises(webob.exc.HTTPInternalServerError, + self.controller.update, + req, '999', body) + self.assertEquals(len(test_notifier.NOTIFICATIONS), 1) + + def test_show(self): + self.stubs.Set(qos_specs, 'get_qos_specs', + return_qos_specs_get_qos_specs) + + req = fakes.HTTPRequest.blank('/v2/fake/qos-specs/1') + res_dict = self.controller.show(req, '1') + + self.assertEqual(1, len(res_dict)) + self.assertEqual('1', res_dict['qos_specs_1']['id']) + + def test_get_associations(self): + self.stubs.Set(qos_specs, 'get_associations', + return_get_qos_associations) + + req = fakes.HTTPRequest.blank( + '/v2/fake/qos-specs/1/associations') + res = self.controller.associations(req, '1') + + self.assertEqual('1', res.keys()[0]) + self.assertEqual('FakeVolTypeName', res['1'].keys()[0]) + self.assertEqual('FakeVolTypeID', + res['1']['FakeVolTypeName']) + + def test_get_associations_not_found(self): + self.stubs.Set(qos_specs, 'get_associations', + return_get_qos_associations) + + req = fakes.HTTPRequest.blank( + '/v2/fake/qos-specs/111/associations') + self.assertRaises(webob.exc.HTTPNotFound, + self.controller.associations, + req, '111') + + def test_get_associations_failed(self): + self.stubs.Set(qos_specs, 'get_associations', + return_get_qos_associations) + + req = fakes.HTTPRequest.blank( + '/v2/fake/qos-specs/222/associations') + self.assertRaises(webob.exc.HTTPInternalServerError, + self.controller.associations, + req, '222') + + def test_associate(self): + self.stubs.Set(qos_specs, 'get_qos_specs', + return_qos_specs_get_qos_specs) + self.stubs.Set(qos_specs, 'associate_qos_with_type', + return_associate_qos_specs) + + req = fakes.HTTPRequest.blank( + '/v2/fake/qos-specs/1/associate?vol_type_id=111') + res = self.controller.associate(req, '1') + + self.assertEqual(res.status_int, 202) + + def test_associate_no_type(self): + self.stubs.Set(qos_specs, 'get_qos_specs', + return_qos_specs_get_qos_specs) + self.stubs.Set(qos_specs, 'associate_qos_with_type', + return_associate_qos_specs) + + req = fakes.HTTPRequest.blank( + '/v2/fake/qos-specs/1/associate') + + self.assertRaises(webob.exc.HTTPBadRequest, + self.controller.associate, req, '1') + + def test_associate_not_found(self): + self.stubs.Set(qos_specs, 'get_qos_specs', + return_qos_specs_get_qos_specs) + self.stubs.Set(qos_specs, 'associate_qos_with_type', + return_associate_qos_specs) + req = fakes.HTTPRequest.blank( + '/v2/fake/qos-specs/111/associate?vol_type_id=12') + self.assertRaises(webob.exc.HTTPNotFound, + self.controller.associate, req, '111') + + req = fakes.HTTPRequest.blank( + '/v2/fake/qos-specs/1/associate?vol_type_id=1234') + + self.assertRaises(webob.exc.HTTPNotFound, + self.controller.associate, req, '1') + + def test_associate_fail(self): + self.stubs.Set(qos_specs, 'get_qos_specs', + return_qos_specs_get_qos_specs) + self.stubs.Set(qos_specs, 'associate_qos_with_type', + return_associate_qos_specs) + req = fakes.HTTPRequest.blank( + '/v2/fake/qos-specs/222/associate?vol_type_id=1000') + self.assertRaises(webob.exc.HTTPInternalServerError, + self.controller.associate, req, '222') + + def test_disassociate(self): + self.stubs.Set(qos_specs, 'get_qos_specs', + return_qos_specs_get_qos_specs) + self.stubs.Set(qos_specs, 'disassociate_qos_specs', + return_associate_qos_specs) + + req = fakes.HTTPRequest.blank( + '/v2/fake/qos-specs/1/disassociate?vol_type_id=111') + res = self.controller.disassociate(req, '1') + self.assertEqual(res.status_int, 202) + + def test_disassociate_no_type(self): + self.stubs.Set(qos_specs, 'get_qos_specs', + return_qos_specs_get_qos_specs) + self.stubs.Set(qos_specs, 'disassociate_qos_specs', + return_associate_qos_specs) + + req = fakes.HTTPRequest.blank( + '/v2/fake/qos-specs/1/disassociate') + + self.assertRaises(webob.exc.HTTPBadRequest, + self.controller.disassociate, req, '1') + + def test_disassociate_not_found(self): + self.stubs.Set(qos_specs, 'get_qos_specs', + return_qos_specs_get_qos_specs) + self.stubs.Set(qos_specs, 'disassociate_qos_specs', + return_associate_qos_specs) + req = fakes.HTTPRequest.blank( + '/v2/fake/qos-specs/111/disassociate?vol_type_id=12') + self.assertRaises(webob.exc.HTTPNotFound, + self.controller.disassociate, req, '111') + + req = fakes.HTTPRequest.blank( + '/v2/fake/qos-specs/1/disassociate?vol_type_id=1234') + self.assertRaises(webob.exc.HTTPNotFound, + self.controller.disassociate, req, '1') + + def test_disassociate_failed(self): + self.stubs.Set(qos_specs, 'get_qos_specs', + return_qos_specs_get_qos_specs) + self.stubs.Set(qos_specs, 'disassociate_qos_specs', + return_associate_qos_specs) + req = fakes.HTTPRequest.blank( + '/v2/fake/qos-specs/333/disassociate?vol_type_id=1000') + self.assertRaises(webob.exc.HTTPInternalServerError, + self.controller.disassociate, req, '333') + + def test_disassociate_all(self): + self.stubs.Set(qos_specs, 'get_qos_specs', + return_qos_specs_get_qos_specs) + self.stubs.Set(qos_specs, 'disassociate_all', + return_disassociate_all) + + req = fakes.HTTPRequest.blank( + '/v2/fake/qos-specs/1/disassociate_all') + res = self.controller.disassociate_all(req, '1') + self.assertEqual(res.status_int, 202) + + def test_disassociate_all_not_found(self): + self.stubs.Set(qos_specs, 'get_qos_specs', + return_qos_specs_get_qos_specs) + self.stubs.Set(qos_specs, 'disassociate_all', + return_disassociate_all) + req = fakes.HTTPRequest.blank( + '/v2/fake/qos-specs/111/disassociate_all') + self.assertRaises(webob.exc.HTTPNotFound, + self.controller.disassociate_all, req, '111') + + def test_disassociate_all_failed(self): + self.stubs.Set(qos_specs, 'get_qos_specs', + return_qos_specs_get_qos_specs) + self.stubs.Set(qos_specs, 'disassociate_all', + return_disassociate_all) + req = fakes.HTTPRequest.blank( + '/v2/fake/qos-specs/222/disassociate_all') + self.assertRaises(webob.exc.HTTPInternalServerError, + self.controller.disassociate_all, req, '222') diff --git a/cinder/tests/db/test_qos_specs.py b/cinder/tests/db/test_qos_specs.py new file mode 100644 index 000000000..7d8f1d43f --- /dev/null +++ b/cinder/tests/db/test_qos_specs.py @@ -0,0 +1,204 @@ +# Copyright (C) 2013 eBay Inc. +# Copyright (C) 2013 OpenStack LLC. +# 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. + +"""Tests for qaulity_of_service_specs table.""" + + +import time + +from cinder import context +from cinder import db +from cinder import exception +from cinder.openstack.common import log as logging +from cinder import test +from cinder.volume import volume_types + + +LOG = logging.getLogger(__name__) + + +def fake_qos_specs_get_by_name(context, name, session=None, inactive=False): + pass + + +class QualityOfServiceSpecsTableTestCase(test.TestCase): + """Test case for QualityOfServiceSpecs model.""" + + def setUp(self): + super(QualityOfServiceSpecsTableTestCase, self).setUp() + self.ctxt = context.RequestContext(user_id='user_id', + project_id='project_id', + is_admin=True) + + def tearDown(self): + super(QualityOfServiceSpecsTableTestCase, self).tearDown() + + def _create_qos_specs(self, name, values=None): + """Create a transfer object.""" + if values: + specs = dict(name=name, qos_specs=values) + else: + specs = {'name': name, + 'qos_specs': { + 'consumer': 'back-end', + 'key1': 'value1', + 'key2': 'value2'}} + return db.qos_specs_create(self.ctxt, specs)['id'] + + def test_qos_specs_create(self): + # If there is qos specs with the same name exists, + # a QoSSpecsExists exception will be raised. + name = 'QoSSpecsCreationTest' + self._create_qos_specs(name) + self.assertRaises(exception.QoSSpecsExists, + db.qos_specs_create, self.ctxt, dict(name=name)) + + specs_id = self._create_qos_specs('NewName') + query_id = db.qos_specs_get_by_name( + self.ctxt, 'NewName')['NewName']['id'] + self.assertEquals(specs_id, query_id) + + def test_qos_specs_get(self): + value = dict(consumer='front-end', + key1='foo', key2='bar') + specs_id = self._create_qos_specs('Name1', value) + + fake_id = 'fake-UUID' + self.assertRaises(exception.QoSSpecsNotFound, + db.qos_specs_get, self.ctxt, fake_id) + + specs = db.qos_specs_get(self.ctxt, specs_id) + value.update(dict(id=specs_id)) + expected = dict(Name1=value) + self.assertDictMatch(specs, expected) + + def test_qos_specs_get_all(self): + value1 = dict(consumer='front-end', + key1='v1', key2='v2') + value2 = dict(consumer='back-end', + key3='v3', key4='v4') + value3 = dict(consumer='back-end', + key5='v5', key6='v6') + + spec_id1 = self._create_qos_specs('Name1', value1) + spec_id2 = self._create_qos_specs('Name2', value2) + spec_id3 = self._create_qos_specs('Name3', value3) + + specs = db.qos_specs_get_all(self.ctxt) + self.assertEquals(len(specs), 3, + "Unexpected number of qos specs records") + + value1.update({'id': spec_id1}) + value2.update({'id': spec_id2}) + value3.update({'id': spec_id3}) + self.assertDictMatch(specs['Name1'], value1) + self.assertDictMatch(specs['Name2'], value2) + self.assertDictMatch(specs['Name3'], value3) + + def test_qos_specs_get_by_name(self): + name = str(int(time.time())) + value = dict(consumer='front-end', + foo='Foo', bar='Bar') + specs_id = self._create_qos_specs(name, value) + specs = db.qos_specs_get_by_name(self.ctxt, name) + value.update(dict(id=specs_id)) + expected = {name: value} + self.assertDictMatch(specs, expected) + + def test_qos_specs_delete(self): + name = str(int(time.time())) + specs_id = self._create_qos_specs(name) + + db.qos_specs_delete(self.ctxt, specs_id) + self.assertRaises(exception.QoSSpecsNotFound, db.qos_specs_get, + self.ctxt, specs_id) + + def test_associate_type_with_qos(self): + self.assertRaises(exception.VolumeTypeNotFound, + db.volume_type_qos_associate, + self.ctxt, 'Fake-VOLID', 'Fake-QOSID') + type_id = volume_types.create(self.ctxt, 'TypeName')['id'] + specs_id = self._create_qos_specs('FakeQos') + db.volume_type_qos_associate(self.ctxt, type_id, specs_id) + res = db.qos_specs_associations_get(self.ctxt, specs_id) + self.assertEquals(len(res), 1) + self.assertEquals(res[0]['id'], type_id) + self.assertEquals(res[0]['qos_specs_id'], specs_id) + + def test_qos_associations_get(self): + self.assertRaises(exception.QoSSpecsNotFound, + db.qos_specs_associations_get, + self.ctxt, 'Fake-UUID') + + type_id = volume_types.create(self.ctxt, 'TypeName')['id'] + specs_id = self._create_qos_specs('FakeQos') + res = db.qos_specs_associations_get(self.ctxt, specs_id) + self.assertEquals(len(res), 0) + + db.volume_type_qos_associate(self.ctxt, type_id, specs_id) + res = db.qos_specs_associations_get(self.ctxt, specs_id) + self.assertEquals(len(res), 1) + self.assertEquals(res[0]['id'], type_id) + self.assertEquals(res[0]['qos_specs_id'], specs_id) + + type0_id = volume_types.create(self.ctxt, 'Type0Name')['id'] + db.volume_type_qos_associate(self.ctxt, type0_id, specs_id) + res = db.qos_specs_associations_get(self.ctxt, specs_id) + self.assertEquals(len(res), 2) + self.assertEquals(res[0]['qos_specs_id'], specs_id) + self.assertEquals(res[1]['qos_specs_id'], specs_id) + + def test_qos_specs_disassociate(self): + type_id = volume_types.create(self.ctxt, 'TypeName')['id'] + specs_id = self._create_qos_specs('FakeQos') + db.volume_type_qos_associate(self.ctxt, type_id, specs_id) + res = db.qos_specs_associations_get(self.ctxt, specs_id) + self.assertEquals(res[0]['id'], type_id) + self.assertEquals(res[0]['qos_specs_id'], specs_id) + + db.qos_specs_disassociate(self.ctxt, specs_id, type_id) + res = db.qos_specs_associations_get(self.ctxt, specs_id) + self.assertEquals(len(res), 0) + res = db.volume_type_get(self.ctxt, type_id) + self.assertEquals(res['qos_specs_id'], None) + + def test_qos_specs_disassociate_all(self): + specs_id = self._create_qos_specs('FakeQos') + type1_id = volume_types.create(self.ctxt, 'Type1Name')['id'] + type2_id = volume_types.create(self.ctxt, 'Type2Name')['id'] + type3_id = volume_types.create(self.ctxt, 'Type3Name')['id'] + db.volume_type_qos_associate(self.ctxt, type1_id, specs_id) + db.volume_type_qos_associate(self.ctxt, type2_id, specs_id) + db.volume_type_qos_associate(self.ctxt, type3_id, specs_id) + + res = db.qos_specs_associations_get(self.ctxt, specs_id) + self.assertEquals(len(res), 3) + + db.qos_specs_disassociate_all(self.ctxt, specs_id) + res = db.qos_specs_associations_get(self.ctxt, specs_id) + self.assertEquals(len(res), 0) + + def test_qos_specs_update(self): + name = 'FakeName' + specs_id = self._create_qos_specs(name) + value = dict(key2='new_value2', key3='value3') + + self.assertRaises(exception.QoSSpecsNotFound, db.qos_specs_update, + self.ctxt, 'Fake-UUID', value) + db.qos_specs_update(self.ctxt, specs_id, value) + specs = db.qos_specs_get(self.ctxt, specs_id) + self.assertEqual(specs[name]['key2'], 'new_value2') + self.assertEqual(specs[name]['key3'], 'value3') diff --git a/cinder/tests/policy.json b/cinder/tests/policy.json index 98f126f84..631331f21 100644 --- a/cinder/tests/policy.json +++ b/cinder/tests/policy.json @@ -39,6 +39,7 @@ "volume_extension:types_extra_specs": [], "volume_extension:volume_type_encryption": [["rule:admin_api"]], "volume_extension:volume_encryption_metadata": [["rule:admin_or_owner"]], + "volume_extension:qos_specs_manage": [], "volume_extension:extended_snapshot_attributes": [], "volume_extension:volume_image_metadata": [], "volume_extension:volume_host_attribute": [["rule:admin_api"]], @@ -51,7 +52,7 @@ "volume:accept_transfer": [], "volume:delete_transfer": [], "volume:get_all_transfers": [], - + "backup:create" : [], "backup:delete": [], "backup:get": [], diff --git a/cinder/tests/test_migrations.py b/cinder/tests/test_migrations.py index 9073aca62..b3791d842 100644 --- a/cinder/tests/test_migrations.py +++ b/cinder/tests/test_migrations.py @@ -646,7 +646,6 @@ class TestMigrations(test.TestCase): metadata.bind = engine migration_api.upgrade(engine, TestMigrations.REPOSITORY, 10) - self.assertTrue(engine.dialect.has_table(engine.connect(), "transfers")) transfers = sqlalchemy.Table('transfers', @@ -845,12 +844,12 @@ class TestMigrations(test.TestCase): def test_migration_017(self): """Test that added encryption information works correctly.""" + + # upgrade schema for (key, engine) in self.engines.items(): migration_api.version_control(engine, TestMigrations.REPOSITORY, migration.INIT_VERSION) - - # upgrade schema migration_api.upgrade(engine, TestMigrations.REPOSITORY, 16) metadata = sqlalchemy.schema.MetaData() metadata.bind = engine @@ -897,3 +896,41 @@ class TestMigrations(test.TestCase): self.assertFalse(engine.dialect.has_table(engine.connect(), 'encryption')) + + def test_migration_018(self): + """Test that added qos_specs table works correctly.""" + for (key, engine) in self.engines.items(): + migration_api.version_control(engine, + TestMigrations.REPOSITORY, + migration.INIT_VERSION) + migration_api.upgrade(engine, TestMigrations.REPOSITORY, 17) + metadata = sqlalchemy.schema.MetaData() + metadata.bind = engine + + migration_api.upgrade(engine, TestMigrations.REPOSITORY, 18) + self.assertTrue(engine.dialect.has_table( + engine.connect(), "quality_of_service_specs")) + qos_specs = sqlalchemy.Table('quality_of_service_specs', + metadata, + autoload=True) + self.assertTrue(isinstance(qos_specs.c.created_at.type, + sqlalchemy.types.DATETIME)) + self.assertTrue(isinstance(qos_specs.c.updated_at.type, + sqlalchemy.types.DATETIME)) + self.assertTrue(isinstance(qos_specs.c.deleted_at.type, + sqlalchemy.types.DATETIME)) + self.assertTrue(isinstance(qos_specs.c.deleted.type, + sqlalchemy.types.BOOLEAN)) + self.assertTrue(isinstance(qos_specs.c.id.type, + sqlalchemy.types.VARCHAR)) + self.assertTrue(isinstance(qos_specs.c.specs_id.type, + sqlalchemy.types.VARCHAR)) + self.assertTrue(isinstance(qos_specs.c.key.type, + sqlalchemy.types.VARCHAR)) + self.assertTrue(isinstance(qos_specs.c.value.type, + sqlalchemy.types.VARCHAR)) + + migration_api.downgrade(engine, TestMigrations.REPOSITORY, 17) + + self.assertFalse(engine.dialect.has_table( + engine.connect(), "quality_of_service_specs")) diff --git a/cinder/tests/test_qos_specs.py b/cinder/tests/test_qos_specs.py new file mode 100644 index 000000000..9143665c9 --- /dev/null +++ b/cinder/tests/test_qos_specs.py @@ -0,0 +1,293 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright (c) 2013 eBay Inc. +# Copyright (c) 2013 OpenStack LLC. +# +# 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. +""" +Unit Tests for qos specs internal API +""" + +import time + +from cinder import context +from cinder import db +from cinder import exception +from cinder.openstack.common.db import exception as db_exc +from cinder.openstack.common import log as logging +from cinder import test +from cinder.volume import qos_specs +from cinder.volume import volume_types + + +LOG = logging.getLogger(__name__) + + +def fake_db_qos_specs_create(context, values): + if values['name'] == 'DupQoSName': + raise exception.QoSSpecsExists(specs_id=values['name']) + elif values['name'] == 'FailQoSName': + raise db_exc.DBError() + + pass + + +class QoSSpecsTestCase(test.TestCase): + """Test cases for qos specs code.""" + def setUp(self): + super(QoSSpecsTestCase, self).setUp() + self.ctxt = context.get_admin_context() + + def _create_qos_specs(self, name, values=None): + """Create a transfer object.""" + if values: + specs = dict(name=name, qos_specs=values) + else: + specs = {'name': name, + 'qos_specs': { + 'consumer': 'back-end', + 'key1': 'value1', + 'key2': 'value2'}} + return db.qos_specs_create(self.ctxt, specs)['id'] + + def test_create(self): + input = {'key1': 'value1', + 'key2': 'value2', + 'key3': 'value3'} + ref = qos_specs.create(self.ctxt, 'FakeName', input) + specs = qos_specs.get_qos_specs(self.ctxt, ref['id']) + input.update(dict(consumer='back-end')) + input.update(dict(id=ref['id'])) + expected = {'FakeName': input} + self.assertDictMatch(specs, expected) + + self.stubs.Set(db, 'qos_specs_create', + fake_db_qos_specs_create) + + # Restore input back to original state + del input['id'] + del input['consumer'] + # qos specs must have unique name + self.assertRaises(exception.QoSSpecsExists, + qos_specs.create, self.ctxt, 'DupQoSName', input) + + input.update({'consumer': 'FakeConsumer'}) + # consumer must be one of: front-end, back-end, both + self.assertRaises(exception.InvalidQoSSpecs, + qos_specs.create, self.ctxt, 'QoSName', input) + + del input['consumer'] + # able to catch DBError + self.assertRaises(exception.QoSSpecsCreateFailed, + qos_specs.create, self.ctxt, 'FailQoSName', input) + + def test_update(self): + def fake_db_update(context, specs_id, values): + raise db_exc.DBError() + + input = {'key1': 'value1', + 'consumer': 'WrongPlace'} + # consumer must be one of: front-end, back-end, both + self.assertRaises(exception.InvalidQoSSpecs, + qos_specs.update, self.ctxt, 'fake_id', input) + + del input['consumer'] + # qos specs must exists + self.assertRaises(exception.QoSSpecsNotFound, + qos_specs.update, self.ctxt, 'fake_id', input) + + specs_id = self._create_qos_specs('Name', input) + qos_specs.update(self.ctxt, specs_id, + {'key1': 'newvalue1', + 'key2': 'value2'}) + specs = qos_specs.get_qos_specs(self.ctxt, specs_id) + self.assertEqual(specs['Name']['key1'], 'newvalue1') + self.assertEqual(specs['Name']['key2'], 'value2') + + self.stubs.Set(db, 'qos_specs_update', fake_db_update) + self.assertRaises(exception.QoSSpecsUpdateFailed, + qos_specs.update, self.ctxt, 'fake_id', input) + + def test_delete(self): + def fake_db_associations_get(context, id): + if id == 'InUse': + return True + else: + return False + + def fake_db_delete(context, id): + if id == 'NotFound': + raise exception.QoSSpecsNotFound(specs_id=id) + + def fake_disassociate_all(context, id): + pass + + self.stubs.Set(db, 'qos_specs_associations_get', + fake_db_associations_get) + self.stubs.Set(qos_specs, 'disassociate_all', + fake_disassociate_all) + self.stubs.Set(db, 'qos_specs_delete', fake_db_delete) + self.assertRaises(exception.InvalidQoSSpecs, + qos_specs.delete, self.ctxt, None) + self.assertRaises(exception.QoSSpecsNotFound, + qos_specs.delete, self.ctxt, 'NotFound') + self.assertRaises(exception.QoSSpecsInUse, + qos_specs.delete, self.ctxt, 'InUse') + # able to delete in-use qos specs if force=True + qos_specs.delete(self.ctxt, 'InUse', force=True) + + def test_get_associations(self): + def fake_db_associate_get(context, id): + if id == 'Trouble': + raise db_exc.DBError() + return [{'name': 'type-1', 'id': 'id-1'}, + {'name': 'type-2', 'id': 'id-2'}] + + self.stubs.Set(db, 'qos_specs_associations_get', + fake_db_associate_get) + expected = {'specs-id': {'type-1': 'id-1', + 'type-2': 'id-2'}} + res = qos_specs.get_associations(self.ctxt, 'specs-id') + self.assertDictMatch(res, expected) + + self.assertRaises(exception.CinderException, + qos_specs.get_associations, self.ctxt, + 'Trouble') + + def test_associate_qos_with_type(self): + def fake_db_associate(context, id, type_id): + if id == 'Trouble': + raise db_exc.DBError() + elif type_id == 'NotFound': + raise exception.VolumeTypeNotFound(volume_type_id=type_id) + pass + + type_ref = volume_types.create(self.ctxt, 'TypeName') + specs_id = self._create_qos_specs('QoSName') + + qos_specs.associate_qos_with_type(self.ctxt, specs_id, + type_ref['id']) + res = qos_specs.get_associations(self.ctxt, specs_id) + self.assertEquals(len(res[specs_id].keys()), 1) + self.assertTrue('TypeName' in res[specs_id].keys()) + self.assertTrue(type_ref['id'] in res[specs_id].values()) + + self.stubs.Set(db, 'qos_specs_associate', + fake_db_associate) + self.assertRaises(exception.VolumeTypeNotFound, + qos_specs.associate_qos_with_type, + self.ctxt, 'specs-id', 'NotFound') + self.assertRaises(exception.QoSSpecsAssociateFailed, + qos_specs.associate_qos_with_type, + self.ctxt, 'Trouble', 'id') + + def test_disassociate_qos_specs(self): + def fake_db_disassociate(context, id, type_id): + if id == 'Trouble': + raise db_exc.DBError() + elif type_id == 'NotFound': + raise exception.VolumeTypeNotFound(volume_type_id=type_id) + pass + + type_ref = volume_types.create(self.ctxt, 'TypeName') + specs_id = self._create_qos_specs('QoSName') + + qos_specs.associate_qos_with_type(self.ctxt, specs_id, + type_ref['id']) + res = qos_specs.get_associations(self.ctxt, specs_id) + self.assertEquals(len(res[specs_id].keys()), 1) + + qos_specs.disassociate_qos_specs(self.ctxt, specs_id, type_ref['id']) + res = qos_specs.get_associations(self.ctxt, specs_id) + self.assertEquals(len(res[specs_id].keys()), 0) + + self.stubs.Set(db, 'qos_specs_disassociate', + fake_db_disassociate) + self.assertRaises(exception.VolumeTypeNotFound, + qos_specs.disassociate_qos_specs, + self.ctxt, 'specs-id', 'NotFound') + self.assertRaises(exception.QoSSpecsDisassociateFailed, + qos_specs.disassociate_qos_specs, + self.ctxt, 'Trouble', 'id') + + def test_disassociate_all(self): + def fake_db_disassociate_all(context, id): + if id == 'Trouble': + raise db_exc.DBError() + pass + + type1_ref = volume_types.create(self.ctxt, 'TypeName1') + type2_ref = volume_types.create(self.ctxt, 'TypeName2') + specs_id = self._create_qos_specs('QoSName') + + qos_specs.associate_qos_with_type(self.ctxt, specs_id, + type1_ref['id']) + qos_specs.associate_qos_with_type(self.ctxt, specs_id, + type2_ref['id']) + res = qos_specs.get_associations(self.ctxt, specs_id) + self.assertEquals(len(res[specs_id].keys()), 2) + + qos_specs.disassociate_all(self.ctxt, specs_id) + res = qos_specs.get_associations(self.ctxt, specs_id) + self.assertEquals(len(res[specs_id].keys()), 0) + + self.stubs.Set(db, 'qos_specs_disassociate_all', + fake_db_disassociate_all) + self.assertRaises(exception.QoSSpecsDisassociateFailed, + qos_specs.disassociate_all, + self.ctxt, 'Trouble') + + def test_get_all_specs(self): + input = {'key1': 'value1', + 'key2': 'value2', + 'key3': 'value3'} + specs_id1 = self._create_qos_specs('Specs1', input) + input.update({'key4': 'value4'}) + specs_id2 = self._create_qos_specs('Specs2', input) + + expected = {'Specs1': {'key1': 'value1', + 'id': specs_id1, + 'key2': 'value2', + 'key3': 'value3'}, + 'Specs2': {'key1': 'value1', + 'id': specs_id2, + 'key2': 'value2', + 'key3': 'value3', + 'key4': 'value4'}} + res = qos_specs.get_all_specs(self.ctxt) + self.assertDictMatch(expected, res) + + def test_get_qos_specs(self): + one_time_value = str(int(time.time())) + input = {'key1': one_time_value, + 'key2': 'value2', + 'key3': 'value3'} + id = self._create_qos_specs('Specs1', input) + specs = qos_specs.get_qos_specs(self.ctxt, id) + self.assertEquals(specs['Specs1']['key1'], one_time_value) + + self.assertRaises(exception.InvalidQoSSpecs, + qos_specs.get_qos_specs, self.ctxt, None) + + def test_get_qos_specs_by_name(self): + one_time_value = str(int(time.time())) + input = {'key1': one_time_value, + 'key2': 'value2', + 'key3': 'value3'} + id = self._create_qos_specs(one_time_value, input) + specs = qos_specs.get_qos_specs_by_name(self.ctxt, + one_time_value) + self.assertEquals(specs[one_time_value]['key1'], one_time_value) + + self.assertRaises(exception.InvalidQoSSpecs, + qos_specs.get_qos_specs_by_name, self.ctxt, None) diff --git a/cinder/tests/test_volume_types.py b/cinder/tests/test_volume_types.py index 60102a75b..743746071 100644 --- a/cinder/tests/test_volume_types.py +++ b/cinder/tests/test_volume_types.py @@ -27,6 +27,7 @@ from cinder import exception from cinder.openstack.common import log as logging from cinder import test from cinder.tests import conf_fixture +from cinder.volume import qos_specs from cinder.volume import volume_types @@ -201,3 +202,22 @@ class VolumeTypeTestCase(test.TestCase): volume_type_id, encryption) self.assertTrue(volume_types.is_encrypted(self.ctxt, volume_type_id)) + + def test_get_volume_type_qos_specs(self): + qos_ref = qos_specs.create(self.ctxt, 'qos-specs-1', {'k1': 'v1', + 'k2': 'v2', + 'k3': 'v3'}) + type_ref = volume_types.create(self.ctxt, "type1", {"key2": "val2", + "key3": "val3"}) + res = volume_types.get_volume_type_qos_specs(type_ref['id']) + self.assertEquals(res['qos_specs'], {}) + qos_specs.associate_qos_with_type(self.ctxt, + qos_ref['id'], + type_ref['id']) + + expected = {'qos_specs': {'consumer': 'back-end', + 'k1': 'v1', + 'k2': 'v2', + 'k3': 'v3'}} + res = volume_types.get_volume_type_qos_specs(type_ref['id']) + self.assertDictMatch(expected, res) diff --git a/cinder/volume/flows/create_volume.py b/cinder/volume/flows/create_volume.py index ea05d3dc5..3eb2933b3 100644 --- a/cinder/volume/flows/create_volume.py +++ b/cinder/volume/flows/create_volume.py @@ -519,6 +519,14 @@ class ExtractVolumeRequestTask(CinderTask): source_volume, backup_source_volume) + specs = {} + if volume_type_id: + qos_specs = volume_types.get_volume_type_qos_specs(volume_type_id) + specs = qos_specs['qos_specs'] + if not specs: + # to make sure we don't pass empty dict + specs = None + self._check_metadata_properties(metadata) return { @@ -529,6 +537,7 @@ class ExtractVolumeRequestTask(CinderTask): 'volume_type': volume_type, 'volume_type_id': volume_type_id, 'encryption_key_id': encryption_key_id, + 'qos_specs': specs, } diff --git a/cinder/volume/manager.py b/cinder/volume/manager.py index 6513c14c3..532f688ed 100644 --- a/cinder/volume/manager.py +++ b/cinder/volume/manager.py @@ -58,6 +58,7 @@ from cinder.volume.configuration import Configuration from cinder.volume.flows import create_volume from cinder.volume import rpcapi as volume_rpcapi from cinder.volume import utils as volume_utils +from cinder.volume import volume_types from cinder.taskflow import states @@ -516,7 +517,21 @@ class VolumeManager(manager.SchedulerDependentManager): """ volume_ref = self.db.volume_get(context, volume_id) self.driver.validate_connector(connector) - return self.driver.initialize_connection(volume_ref, connector) + conn_info = self.driver.initialize_connection(volume_ref, connector) + + # Add qos_specs to connection info + typeid = volume_ref['volume_type_id'] + specs = {} + if typeid: + res = volume_types.get_volume_type_qos_specs(typeid) + specs = res['qos_specs'] + + # Don't pass qos_spec as empty dict + qos_spec = dict(qos_spec=specs if specs else None) + + conn_info['data'].update(qos_spec) + + return conn_info def terminate_connection(self, context, volume_id, connector, force=False): """Cleanup connection from host represented by connector. diff --git a/cinder/volume/qos_specs.py b/cinder/volume/qos_specs.py new file mode 100644 index 000000000..2b933cc40 --- /dev/null +++ b/cinder/volume/qos_specs.py @@ -0,0 +1,250 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright (c) 2013 eBay Inc. +# Copyright (c) 2013 OpenStack LLC. +# +# 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 QoS Specs Implementation""" + + +from oslo.config import cfg + +from cinder import context +from cinder import db +from cinder import exception +from cinder.openstack.common.db import exception as db_exc +from cinder.openstack.common import log as logging + + +CONF = cfg.CONF +LOG = logging.getLogger(__name__) + +CONTROL_LOCATION = ['front-end', 'back-end', 'both'] + + +def _verify_prepare_qos_specs(specs, create=True): + """Check if 'consumer' value in qos specs is valid. + + Verify 'consumer' value in qos_specs is valid, raise + exception if not. Assign default value to 'consumer', which + is 'back-end' if input is empty. + + :params create a flag indicate if specs being verified is + for create. If it's false, that means specs is for update, + so that there's no need to add 'consumer' if that wasn't in + specs. + """ + + # Check control location, if it's missing in input, assign default + # control location: 'front-end' + if not specs: + specs = {} + # remove 'name' since we will handle that elsewhere. + if specs.get('name', None): + del specs['name'] + try: + if specs['consumer'] not in CONTROL_LOCATION: + msg = _("Valid consumer of QoS specs are: %s") % CONTROL_LOCATION + raise exception.InvalidQoSSpecs(reason=msg) + except KeyError: + # Default consumer is back-end, i.e Cinder volume service + if create: + specs['consumer'] = 'back-end' + + return specs + + +def create(context, name, specs=None): + """Creates qos_specs. + + :param specs dictionary that contains specifications for QoS + e.g. {'consumer': 'front-end', + 'total_iops_sec': 1000, + 'total_bytes_sec': 1024000} + """ + _verify_prepare_qos_specs(specs) + + values = dict(name=name, qos_specs=specs) + + LOG.debug("Dict for qos_specs: %s" % values) + + try: + qos_specs_ref = db.qos_specs_create(context, values) + except db_exc.DBError as e: + LOG.exception(_('DB error: %s') % e) + raise exception.QoSSpecsCreateFailed(name=name, + qos_specs=specs) + return qos_specs_ref + + +def update(context, qos_specs_id, specs): + """Update qos specs. + + :param specs dictionary that contains key/value pairs for updating + existing specs. + e.g. {'consumer': 'front-end', + 'total_iops_sec': 500, + 'total_bytes_sec': 512000,} + """ + # need to verify specs in case 'consumer' is passed + _verify_prepare_qos_specs(specs, create=False) + LOG.debug('qos_specs.update(): specs %s' % specs) + try: + res = db.qos_specs_update(context, qos_specs_id, specs) + except db_exc.DBError as e: + LOG.exception(_('DB error: %s') % e) + raise exception.QoSSpecsUpdateFailed(specs_id=qos_specs_id, + qos_specs=specs) + + return res + + +def delete(context, qos_specs_id, force=False): + """Marks qos specs as deleted. + + 'force' parameter is a flag to determine whether should destroy + should continue when there were entities associated with the qos specs. + force=True indicates caller would like to mark qos specs as deleted + even if there was entities associate with target qos specs. + Trying to delete a qos specs still associated with entities will + cause QoSSpecsInUse exception if force=False (default). + """ + if qos_specs_id is None: + msg = _("id cannot be None") + raise exception.InvalidQoSSpecs(reason=msg) + else: + # check if there is any entity associated with this + # qos specs. + res = db.qos_specs_associations_get(context, qos_specs_id) + if res and not force: + raise exception.QoSSpecsInUse(specs_id=qos_specs_id) + elif force: + # remove all association + disassociate_all(context, qos_specs_id) + db.qos_specs_delete(context, qos_specs_id) + + +def get_associations(context, specs_id): + """Get all associations of given qos specs.""" + try: + # query returns a list of volume types associated with qos specs + associates = db.qos_specs_associations_get(context, specs_id) + except db_exc.DBError as e: + LOG.exception(_('DB error: %s') % e) + msg = _('Failed to get all associations of ' + 'qos specs %s') % specs_id + LOG.warn(msg) + raise exception.CinderException(message=msg) + + result = {} + for vol_type in associates: + result[vol_type['name']] = vol_type['id'] + + return {specs_id: result} + + +def associate_qos_with_type(context, specs_id, type_id): + """Associate qos_specs from volume type.""" + try: + db.qos_specs_associate(context, specs_id, type_id) + except db_exc.DBError as e: + LOG.exception(_('DB error: %s') % e) + LOG.warn(_('Failed to associate qos specs ' + '%(id)s with type: %(vol_type_id)s') % + dict(id=specs_id, vol_type_id=type_id)) + raise exception.QoSSpecsAssociateFailed(specs_id=specs_id, + type_id=type_id) + + +def disassociate_qos_specs(context, specs_id, type_id): + """Disassociate qos_specs from volume type.""" + try: + db.qos_specs_disassociate(context, specs_id, type_id) + except db_exc.DBError as e: + LOG.exception(_('DB error: %s') % e) + LOG.warn(_('Failed to disassociate qos specs ' + '%(id)s with type: %(vol_type_id)s') % + dict(id=specs_id, vol_type_id=type_id)) + raise exception.QoSSpecsDisassociateFailed(specs_id=specs_id, + type_id=type_id) + + +def disassociate_all(context, specs_id): + """Disassociate qos_specs from all entities.""" + try: + db.qos_specs_disassociate_all(context, specs_id) + except db_exc.DBError as e: + LOG.exception(_('DB error: %s') % e) + LOG.warn(_('Failed to disassociate qos specs %s.') % specs_id) + raise exception.QoSSpecsDisassociateFailed(specs_id=specs_id, + type_id=None) + + +def get_all_specs(context, inactive=False, search_opts={}): + """Get all non-deleted qos specs. + + Pass inactive=True as argument and deleted volume types would return + as well. + """ + qos_specs = db.qos_specs_get_all(context, inactive) + + if search_opts: + LOG.debug(_("Searching by: %s") % str(search_opts)) + + def _check_specs_match(qos_specs, searchdict): + for k, v in searchdict.iteritems(): + if ((k not in qos_specs['specs'].keys() or + qos_specs['specs'][k] != v)): + return False + return True + + # search_option to filter_name mapping. + filter_mapping = {'qos_specs': _check_specs_match} + + result = {} + for name, args in qos_specs.iteritems(): + # go over all filters in the list + for opt, values in search_opts.iteritems(): + try: + filter_func = filter_mapping[opt] + except KeyError: + # no such filter - ignore it, go to next filter + continue + else: + if filter_func(args, values): + result[name] = args + break + qos_specs = result + return qos_specs + + +def get_qos_specs(ctxt, id): + """Retrieves single qos specs by id.""" + if id is None: + msg = _("id cannot be None") + raise exception.InvalidQoSSpecs(reason=msg) + + if ctxt is None: + ctxt = context.get_admin_context() + + return db.qos_specs_get(ctxt, id) + + +def get_qos_specs_by_name(context, name): + """Retrieves single qos specs by name.""" + if name is None: + msg = _("name cannot be None") + raise exception.InvalidQoSSpecs(reason=msg) + + return db.qos_specs_get_by_name(context, name) diff --git a/cinder/volume/volume_types.py b/cinder/volume/volume_types.py index e0161c3a7..01d9cdd28 100644 --- a/cinder/volume/volume_types.py +++ b/cinder/volume/volume_types.py @@ -168,3 +168,10 @@ def is_encrypted(context, volume_type_id): encryption = db.volume_type_encryption_get(context, volume_type_id) return encryption is not None + + +def get_volume_type_qos_specs(volume_type_id): + ctxt = context.get_admin_context() + res = db.volume_type_qos_specs_get(ctxt, + volume_type_id) + return res -- 2.45.2