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:
parent
c523afa0fd
commit
e323d9c44a
@ -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:
|
||||
|
@ -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
|
||||
|
170
cinder/tests/functional/test_quotas.py
Normal file
170
cinder/tests/functional/test_quotas.py
Normal 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)
|
@ -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)
|
||||
|
@ -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')
|
||||
|
Loading…
x
Reference in New Issue
Block a user