Add functional tests for nested quotas

Adds functional tests for nested quotas as an alternative
to creating a Tempest test + gate job that would only run
Cinder related quota checks.

NOTE: The QUOTA engine in Cinder is a global variable that lazy loads
the quota driver, so even if we change the config for the quota driver,
we won't reliably change the driver being used (or change it back)
unless the global variables get cleaned up, so using mock instead of
overriding _get_flags to change the quota driver.

Co-Authored-By: Xinli Guan <xinli@us.ibm.com>

Change-Id: Ic9b7ef3c8e4978c1f1fee38a5c2511ba5cbe5559
This commit is contained in:
Ryan McNair 2016-03-24 14:20:09 +00:00 committed by Xinli Guan
parent c523afa0fd
commit e323d9c44a
5 changed files with 248 additions and 62 deletions

View File

@ -22,40 +22,37 @@ from cinder.tests.unit import fake_constants as fake
class OpenStackApiException(Exception):
def __init__(self, message=None, response=None):
message = 'Unspecified error'
def __init__(self, response=None, msg=None):
self.response = response
if not message:
message = 'Unspecified error'
# Give chance to override default message
if msg:
self.message = msg
if response:
message = _('%(message)s\nStatus Code: %(_status)s\n'
'Body: %(_body)s') % {'_status': response.status_code,
'_body': response.text}
self.message = _(
'%(message)s\nStatus Code: %(_status)s\nBody: %(_body)s') % {
'_status': response.status_code, '_body': response.text,
'message': self.message}
super(OpenStackApiException, self).__init__(message)
super(OpenStackApiException, self).__init__(self.message)
class OpenStackApiAuthenticationException(OpenStackApiException):
def __init__(self, response=None, message=None):
if not message:
message = _("Authentication error")
super(OpenStackApiAuthenticationException, self).__init__(message,
response)
class OpenStackApiException401(OpenStackApiException):
message = _("401 Unauthorized Error")
class OpenStackApiAuthorizationException(OpenStackApiException):
def __init__(self, response=None, message=None):
if not message:
message = _("Authorization error")
super(OpenStackApiAuthorizationException, self).__init__(message,
response)
class OpenStackApiException404(OpenStackApiException):
message = _("404 Not Found Error")
class OpenStackApiNotFoundException(OpenStackApiException):
def __init__(self, response=None, message=None):
if not message:
message = _("Item not found")
super(OpenStackApiNotFoundException, self).__init__(message, response)
class OpenStackApiException413(OpenStackApiException):
message = _("413 Request entity too large")
class OpenStackApiException400(OpenStackApiException):
message = _("400 Bad Request")
class TestOpenStackClient(object):
@ -102,8 +99,8 @@ class TestOpenStackClient(object):
return response
def _authenticate(self):
if self.auth_result:
def _authenticate(self, reauthenticate=False):
if self.auth_result and not reauthenticate:
return self.auth_result
auth_uri = self.auth_uri
@ -116,11 +113,15 @@ class TestOpenStackClient(object):
http_status = response.status_code
if http_status == 401:
raise OpenStackApiAuthenticationException(response=response)
raise OpenStackApiException401(response=response)
self.auth_result = response.headers
return self.auth_result
def update_project(self, new_project_id):
self.project_id = new_project_id
self._authenticate(True)
def api_request(self, relative_uri, check_response_status=None, **kwargs):
auth_result = self._authenticate()
@ -135,17 +136,15 @@ class TestOpenStackClient(object):
response = self.request(full_uri, **kwargs)
http_status = response.status_code
if check_response_status:
if http_status not in check_response_status:
if http_status == 404:
raise OpenStackApiNotFoundException(response=response)
elif http_status == 401:
raise OpenStackApiAuthorizationException(response=response)
else:
raise OpenStackApiException(
message=_("Unexpected status code"),
response=response)
message = None
try:
exc = globals()["OpenStackApiException%s" % http_status]
except KeyError:
exc = OpenStackApiException
message = _("Unexpected status code")
raise exc(response, message)
return response
@ -204,6 +203,16 @@ class TestOpenStackClient(object):
def put_volume(self, volume_id, volume):
return self.api_put('/volumes/%s' % volume_id, volume)['volume']
def quota_set(self, project_id, quota_update):
return self.api_put(
'os-quota-sets/%s' % project_id,
{'quota_set': quota_update})['quota_set']
def quota_get(self, project_id, usage=True):
return self.api_get('os-quota-sets/%s?usage=%s'
% (project_id, usage))['quota_set']
def create_type(self, type_name, extra_specs=None):
type = {"volume_type": {"name": type_name}}
if extra_specs:

View File

@ -19,6 +19,7 @@ Provides common functionality for functional tests
import os.path
import random
import string
import time
import uuid
import fixtures
@ -80,6 +81,9 @@ class _FunctionalTestBase(test.TestCase):
self.api = client.TestOpenStackClient(fake.USER_ID,
fake.PROJECT_ID, self.auth_url)
def _update_project(self, new_project_id):
self.api.update_project(new_project_id)
def _start_api_service(self):
default_conf = os.path.abspath(os.path.join(
os.path.dirname(__file__), '..', '..', '..',
@ -138,3 +142,28 @@ class _FunctionalTestBase(test.TestCase):
server_name = self.get_unused_server_name()
server['name'] = server_name
return server
def _poll_volume_while(self, volume_id, continue_states,
expected_end_status=None, max_retries=5):
"""Poll (briefly) while the state is in continue_states.
Continues until the state changes from continue_states or max_retries
are hit. If expected_end_status is specified, we assert that the end
status of the volume is expected_end_status.
"""
retries = 0
while retries <= max_retries:
try:
found_volume = self.api.get_volume(volume_id)
except client.OpenStackApiException404:
return None
self.assertEqual(volume_id, found_volume['id'])
vol_status = found_volume['status']
if vol_status not in continue_states:
if expected_end_status:
self.assertEqual(expected_end_status, vol_status)
return found_volume
time.sleep(1)
retries += 1

View File

@ -0,0 +1,170 @@
# 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 mock
import uuid
from cinder import quota
from cinder.tests.functional.api import client
from cinder.tests.functional import functional_helpers
from cinder.tests.unit import fake_driver
class NestedQuotasTest(functional_helpers._FunctionalTestBase):
_vol_type_name = 'functional_test_type'
def setUp(self):
super(NestedQuotasTest, self).setUp()
self.api.create_type(self._vol_type_name)
fake_driver.LoggingVolumeDriver.clear_logs()
self._create_project_hierarchy()
# Need to mock out Keystone so the functional tests don't require other
# services
_keystone_client = mock.MagicMock()
_keystone_client.version = 'v3'
_keystone_client.projects.get.side_effect = self._get_project
_keystone_client_get = mock.patch(
'cinder.quota_utils._keystone_client',
lambda *args, **kwargs: _keystone_client)
_keystone_client_get.start()
self.addCleanup(_keystone_client_get.stop)
# The QUOTA engine in Cinder is a global variable that lazy loads the
# quota driver, so even if we change the config for the quota driver,
# we won't reliably change the driver being used (or change it back)
# unless the global variables get cleaned up, so using mock instead to
# simulate this change
nested_driver = quota.NestedDbQuotaDriver()
_driver_patcher = mock.patch(
'cinder.quota.QuotaEngine._driver', new=nested_driver)
_driver_patcher.start()
self.addCleanup(_driver_patcher.stop)
# Default to using the top parent in the hierarchy
self._update_project(self.A.id)
def _get_flags(self):
f = super(NestedQuotasTest, self)._get_flags()
f['volume_driver'] = \
'cinder.tests.unit.fake_driver.LoggingVolumeDriver'
f['default_volume_type'] = self._vol_type_name
return f
# Currently we use 413 error for over quota
over_quota_exception = client.OpenStackApiException413
def _create_project_hierarchy(self):
"""Sets up the nested hierarchy show below.
+-----------+
| A |
| / \ |
| B C |
| / |
| D |
+-----------+
"""
self.A = self.FakeProject()
self.B = self.FakeProject(parent_id=self.A.id)
self.C = self.FakeProject(parent_id=self.A.id)
self.D = self.FakeProject(parent_id=self.B.id)
self.B.subtree = {self.D.id: self.D.subtree}
self.A.subtree = {self.B.id: self.B.subtree, self.C.id: self.C.subtree}
self.A.parents = None
self.B.parents = {self.A.id: None}
self.C.parents = {self.A.id: None}
self.D.parents = {self.B.id: self.B.parents}
# project_by_id attribute is used to recover a project based on its id.
self.project_by_id = {self.A.id: self.A, self.B.id: self.B,
self.C.id: self.C, self.D.id: self.D}
class FakeProject(object):
_dom_id = uuid.uuid4().hex
def __init__(self, parent_id=None):
self.id = uuid.uuid4().hex
self.parent_id = parent_id
self.domain_id = self._dom_id
self.subtree = None
self.parents = None
def _get_project(self, project_id, *args, **kwargs):
return self.project_by_id[project_id]
def _create_volume(self):
return self.api.post_volume({'volume': {'size': 1}})
def test_default_quotas_enforced(self):
# Should be able to create volume on parent project by default
created_vol = self._create_volume()
self._poll_volume_while(created_vol['id'], ['creating'], 'available')
self._update_project(self.B.id)
# Shouldn't be able to create volume on child project by default
self.assertRaises(self.over_quota_exception, self._create_volume)
def test_update_child_with_parent_default_quota(self):
# Make sure we can update to a reasonable value
self.api.quota_set(self.B.id, {'volumes': 5})
# Ensure that the update took and we can create a volume
self._poll_volume_while(
self._create_volume()['id'], ['creating'], 'available')
def test_quota_update_child_greater_than_parent(self):
self.assertRaises(
client.OpenStackApiException400,
self.api.quota_set, self.B.id, {'volumes': 11})
def test_child_soft_limit_propagates_to_parent(self):
self.api.quota_set(self.B.id, {'volumes': 0})
self.api.quota_set(self.D.id, {'volumes': -1})
self._update_project(self.D.id)
self.assertRaises(self.over_quota_exception, self._create_volume)
def test_child_quota_hard_limits_affects_parents_allocated(self):
self.api.quota_set(self.B.id, {'volumes': 5})
self.api.quota_set(self.C.id, {'volumes': 3})
alloc = self.api.quota_get(self.A.id)['volumes']['allocated']
self.assertEqual(8, alloc)
self.assertRaises(client.OpenStackApiException400,
self.api.quota_set, self.C.id, {'volumes': 6})
def _update_quota_and_def_type(self, project_id, quota):
self.api.quota_set(project_id, quota)
type_updates = {'%s_%s' % (key, self._vol_type_name): val for key, val
in quota.items() if key != 'per_volume_gigabytes'}
return self.api.quota_set(project_id, type_updates)
def test_grandchild_soft_limit_propogates_up(self):
quota = {'volumes': -1, 'gigabytes': -1, 'per_volume_gigabytes': -1}
self._update_quota_and_def_type(self.B.id, quota)
self._update_quota_and_def_type(self.D.id, quota)
self._update_project(self.D.id)
# Create two volumes in the grandchild project and ensure grandparent's
# allocated is updated accordingly
vol = self._create_volume()
self._create_volume()
self._update_project(self.A.id)
alloc = self.api.quota_get(self.A.id)['volumes']['allocated']
self.assertEqual(2, alloc)
alloc = self.api.quota_get(self.B.id)['volumes']['allocated']
self.assertEqual(2, alloc)
# Ensure delete reduces the quota
self._update_project(self.D.id)
self.api.delete_volume(vol['id'])
self._poll_volume_while(vol['id'], ['deleting'])
self._update_project(self.A.id)
alloc = self.api.quota_get(self.A.id)['volumes']['allocated']
self.assertEqual(1, alloc)
alloc = self.api.quota_get(self.B.id)['volumes']['allocated']
self.assertEqual(1, alloc)

View File

@ -13,10 +13,7 @@
# License for the specific language governing permissions and limitations
# under the License.
import time
from cinder.tests import fake_driver
from cinder.tests.functional.api import client
from cinder.tests.functional import functional_helpers
@ -45,27 +42,6 @@ class VolumesTest(functional_helpers._FunctionalTestBase):
volumes = self.api.get_volumes()
self.assertIsNotNone(volumes)
def _poll_while(self, volume_id, continue_states, max_retries=5):
"""Poll (briefly) while the state is in continue_states."""
retries = 0
while True:
try:
found_volume = self.api.get_volume(volume_id)
except client.OpenStackApiNotFoundException:
found_volume = None
break
self.assertEqual(volume_id, found_volume['id'])
if found_volume['status'] not in continue_states:
break
time.sleep(1)
retries = retries + 1
if retries > max_retries:
break
return found_volume
def test_create_and_delete_volume(self):
"""Creates and deletes a volume."""
@ -85,7 +61,7 @@ class VolumesTest(functional_helpers._FunctionalTestBase):
self.assertIn(created_volume_id, volume_names)
# Wait (briefly) for creation. Delay is due to the 'message queue'
found_volume = self._poll_while(created_volume_id, ['creating'])
found_volume = self._poll_volume_while(created_volume_id, ['creating'])
# It should be available...
self.assertEqual('available', found_volume['status'])
@ -94,7 +70,7 @@ class VolumesTest(functional_helpers._FunctionalTestBase):
self.api.delete_volume(created_volume_id)
# Wait (briefly) for deletion. Delay is due to the 'message queue'
found_volume = self._poll_while(created_volume_id, ['deleting'])
found_volume = self._poll_volume_while(created_volume_id, ['deleting'])
# Should be gone
self.assertFalse(found_volume)

View File

@ -52,4 +52,6 @@ def set_defaults(conf):
conf.set_default('state_path', os.path.abspath(
os.path.join(os.path.dirname(__file__), '..', '..', '..')))
conf.set_default('policy_dirs', [], group='oslo_policy')
# This is where we don't authenticate
conf.set_default('auth_strategy', 'noauth')
conf.set_default('auth_uri', 'fake', 'keystone_authtoken')