From 8eae0ecdd95d040dfb4a7d5b4d6d8653e2fc9dd5 Mon Sep 17 00:00:00 2001 From: Stephen Finucane Date: Mon, 25 Nov 2024 16:47:46 +0000 Subject: [PATCH] api: Add response body schemas for limits API Change-Id: Iec1c43dc2ee34488afd337eb65f4245def460e16 Signed-off-by: Stephen Finucane --- nova/api/openstack/compute/limits.py | 26 +- nova/api/openstack/compute/schemas/limits.py | 129 ++++++++- nova/api/openstack/compute/views/limits.py | 2 +- nova/quota.py | 12 +- .../unit/api/openstack/compute/test_limits.py | 256 ++++++------------ nova/tests/unit/policies/test_limits.py | 13 +- 6 files changed, 236 insertions(+), 202 deletions(-) diff --git a/nova/api/openstack/compute/limits.py b/nova/api/openstack/compute/limits.py index 58e46cf2feed..ada93f9521b1 100644 --- a/nova/api/openstack/compute/limits.py +++ b/nova/api/openstack/compute/limits.py @@ -14,7 +14,7 @@ # under the License. from nova.api.openstack import api_version_request -from nova.api.openstack.compute.schemas import limits +from nova.api.openstack.compute.schemas import limits as schema from nova.api.openstack.compute.views import limits as limits_views from nova.api.openstack import wsgi from nova.api import validation @@ -27,26 +27,32 @@ QUOTAS = quota.QUOTAS # This is a list of limits which needs to filter out from the API response. # This is due to the deprecation of network related proxy APIs, the related # limit should be removed from the API also. -FILTERED_LIMITS_2_36 = ['floating_ips', 'security_groups', - 'security_group_rules'] +FILTERED_LIMITS_v236 = [ + 'floating_ips', 'security_groups', 'security_group_rules' +] -FILTERED_LIMITS_2_57 = list(FILTERED_LIMITS_2_36) -FILTERED_LIMITS_2_57.extend(['injected_files', 'injected_file_content_bytes']) +FILTERED_LIMITS_v257 = list(FILTERED_LIMITS_v236) +FILTERED_LIMITS_v257.extend(['injected_files', 'injected_file_content_bytes']) +@validation.validated class LimitsController(wsgi.Controller): """Controller for accessing limits in the OpenStack API.""" @wsgi.expected_errors(()) - @validation.query_schema(limits.limits_query_schema, '2.1', '2.56') - @validation.query_schema(limits.limits_query_schema, '2.57', '2.74') - @validation.query_schema(limits.limits_query_schema_275, '2.75') + @validation.query_schema(schema.index_query, '2.1', '2.56') + @validation.query_schema(schema.index_query, '2.57', '2.74') + @validation.query_schema(schema.index_query_v275, '2.75') + @validation.response_body_schema(schema.index_response, '2.1', '2.35') + @validation.response_body_schema(schema.index_response_v236, '2.36', '2.38') # noqa: E501 + @validation.response_body_schema(schema.index_response_v239, '2.39', '2.56') # noqa: E501 + @validation.response_body_schema(schema.index_response_v257, '2.57') def index(self, req): filtered_limits = [] if api_version_request.is_supported(req, '2.57'): - filtered_limits = FILTERED_LIMITS_2_57 + filtered_limits = FILTERED_LIMITS_v257 elif api_version_request.is_supported(req, '2.36'): - filtered_limits = FILTERED_LIMITS_2_36 + filtered_limits = FILTERED_LIMITS_v236 max_image_meta = True if api_version_request.is_supported(req, '2.39'): diff --git a/nova/api/openstack/compute/schemas/limits.py b/nova/api/openstack/compute/schemas/limits.py index e269cc55ab05..ce5711ab3d3e 100644 --- a/nova/api/openstack/compute/schemas/limits.py +++ b/nova/api/openstack/compute/schemas/limits.py @@ -16,7 +16,7 @@ import copy from nova.api.validation import parameter_types -limits_query_schema = { +index_query = { 'type': 'object', 'properties': { 'tenant_id': parameter_types.common_query_param, @@ -27,5 +27,128 @@ limits_query_schema = { 'additionalProperties': True } -limits_query_schema_275 = copy.deepcopy(limits_query_schema) -limits_query_schema_275['additionalProperties'] = False +index_query_v275 = copy.deepcopy(index_query) +index_query_v275['additionalProperties'] = False + +_absolute_quota_response = { + 'type': 'object', + 'properties': { + 'maxImageMeta': {'type': 'integer', 'minimum': -1}, + 'maxPersonality': {'type': 'integer', 'minimum': -1}, + 'maxPersonalitySize': {'type': 'integer', 'minimum': -1}, + 'maxSecurityGroups': {'type': 'integer', 'minimum': -1}, + 'maxSecurityGroupRules': {'type': 'integer', 'minimum': -1}, + 'maxServerMeta': {'type': 'integer', 'minimum': -1}, + 'maxServerGroups': {'type': 'integer', 'minimum': -1}, + 'maxServerGroupMembers': {'type': 'integer', 'minimum': -1}, + 'maxTotalCores': {'type': 'integer', 'minimum': -1}, + 'maxTotalFloatingIps': {'type': 'integer', 'minimum': -1}, + 'maxTotalInstances': {'type': 'integer', 'minimum': -1}, + 'maxTotalKeypairs': {'type': 'integer', 'minimum': -1}, + 'maxTotalRAMSize': {'type': 'integer', 'minimum': -1}, + 'totalCoresUsed': {'type': 'integer', 'minimum': -1}, + 'totalFloatingIpsUsed': {'type': 'integer', 'minimum': -1}, + 'totalInstancesUsed': {'type': 'integer', 'minimum': -1}, + 'totalRAMUsed': {'type': 'integer', 'minimum': -1}, + 'totalSecurityGroupsUsed': {'type': 'integer', 'minimum': -1}, + 'totalServerGroupsUsed': {'type': 'integer', 'minimum': -1}, + }, + 'required': [ + 'maxImageMeta', + 'maxPersonality', + 'maxPersonalitySize', + 'maxSecurityGroups', + 'maxSecurityGroupRules', + 'maxServerMeta', + 'maxServerGroups', + 'maxServerGroupMembers', + 'maxTotalCores', + 'maxTotalFloatingIps', + 'maxTotalInstances', + 'maxTotalKeypairs', + 'maxTotalRAMSize', + 'totalCoresUsed', + 'totalFloatingIpsUsed', + 'totalInstancesUsed', + 'totalRAMUsed', + 'totalSecurityGroupsUsed', + 'totalServerGroupsUsed', + ], + 'additionalProperties': False, +} + +_absolute_quota_response_v236 = copy.deepcopy(_absolute_quota_response) +del _absolute_quota_response_v236['properties']['maxSecurityGroups'] +del _absolute_quota_response_v236['properties']['maxSecurityGroupRules'] +del _absolute_quota_response_v236['properties']['maxTotalFloatingIps'] +del _absolute_quota_response_v236['properties']['totalFloatingIpsUsed'] +del _absolute_quota_response_v236['properties']['totalSecurityGroupsUsed'] +_absolute_quota_response_v236['required'].pop( + _absolute_quota_response_v236['required'].index('maxSecurityGroups') +) +_absolute_quota_response_v236['required'].pop( + _absolute_quota_response_v236['required'].index('maxSecurityGroupRules') +) +_absolute_quota_response_v236['required'].pop( + _absolute_quota_response_v236['required'].index('maxTotalFloatingIps') +) +_absolute_quota_response_v236['required'].pop( + _absolute_quota_response_v236['required'].index('totalFloatingIpsUsed') +) +_absolute_quota_response_v236['required'].pop( + _absolute_quota_response_v236['required'].index('totalSecurityGroupsUsed') +) + +_absolute_quota_response_v239 = copy.deepcopy(_absolute_quota_response_v236) +del _absolute_quota_response_v239['properties']['maxImageMeta'] +_absolute_quota_response_v239['required'].pop( + _absolute_quota_response_v239['required'].index('maxImageMeta') +) + +_absolute_quota_response_v257 = copy.deepcopy(_absolute_quota_response_v239) +del _absolute_quota_response_v257['properties']['maxPersonality'] +del _absolute_quota_response_v257['properties']['maxPersonalitySize'] +_absolute_quota_response_v257['required'].pop( + _absolute_quota_response_v257['required'].index('maxPersonality') +) +_absolute_quota_response_v257['required'].pop( + _absolute_quota_response_v257['required'].index('maxPersonalitySize') +) + +index_response = { + 'type': 'object', + 'properties': { + 'limits': { + 'type': 'object', + 'properties': { + 'absolute': _absolute_quota_response, + 'rate': { + 'type': 'array', + # Yes, this is an empty array + 'items': {}, + 'maxItems': 0, + 'additionalItems': False, + }, + }, + 'required': ['absolute', 'rate'], + 'additionalProperties': False, + }, + }, + 'required': ['limits'], + 'additionalProperties': False, +} + +index_response_v236 = copy.deepcopy(index_response) +index_response_v236['properties']['limits']['properties']['absolute'] = ( + _absolute_quota_response_v236 +) + +index_response_v239 = copy.deepcopy(index_response) +index_response_v239['properties']['limits']['properties']['absolute'] = ( + _absolute_quota_response_v239 +) + +index_response_v257 = copy.deepcopy(index_response_v236) +index_response_v257['properties']['limits']['properties']['absolute'] = ( + _absolute_quota_response_v257 +) diff --git a/nova/api/openstack/compute/views/limits.py b/nova/api/openstack/compute/views/limits.py index 60870cbe4e34..7b69ddaff523 100644 --- a/nova/api/openstack/compute/views/limits.py +++ b/nova/api/openstack/compute/views/limits.py @@ -33,7 +33,7 @@ class ViewBuilder(object): "security_group_rules": ["maxSecurityGroupRules"], "server_groups": ["maxServerGroups"], "server_group_members": ["maxServerGroupMembers"] - } + } def build(self, request, quotas, filtered_limits=None, max_image_meta=True): diff --git a/nova/quota.py b/nova/quota.py index 162c57df63e4..250fd12994c9 100644 --- a/nova/quota.py +++ b/nova/quota.py @@ -1418,22 +1418,22 @@ def _server_group_count(context, project_id, user_id=None): QUOTAS = QuotaEngine( resources=[ - CountableResource( - 'instances', _instances_cores_ram_count, 'instances'), CountableResource( 'cores', _instances_cores_ram_count, 'cores'), - CountableResource( - 'ram', _instances_cores_ram_count, 'ram'), - AbsoluteResource( - 'metadata_items', 'metadata_items'), AbsoluteResource( 'injected_files', 'injected_files'), AbsoluteResource( 'injected_file_content_bytes', 'injected_file_content_bytes'), AbsoluteResource( 'injected_file_path_bytes', 'injected_file_path_length'), + CountableResource( + 'instances', _instances_cores_ram_count, 'instances'), CountableResource( 'key_pairs', _keypair_get_count_by_user, 'key_pairs'), + AbsoluteResource( + 'metadata_items', 'metadata_items'), + CountableResource( + 'ram', _instances_cores_ram_count, 'ram'), CountableResource( 'server_groups', _server_group_count, 'server_groups'), CountableResource( diff --git a/nova/tests/unit/api/openstack/compute/test_limits.py b/nova/tests/unit/api/openstack/compute/test_limits.py index 1748023aa825..2526c955bf5b 100644 --- a/nova/tests/unit/api/openstack/compute/test_limits.py +++ b/nova/tests/unit/api/openstack/compute/test_limits.py @@ -39,22 +39,40 @@ from nova.tests.unit.api.openstack import fakes from nova.tests.unit import matchers +def fake_get_project_quotas(context, project_id, usages=True): + absolute_limits = { + 'cores': -1, + 'floating_ips': 10, + 'injected_files': -1, + 'injected_file_content_bytes': -1, + 'injected_file_path_bytes': -1, + 'instances': 5, + 'key_pairs': -1, + 'metadata_items': -1, + 'ram': 512, + 'server_groups': 10, + 'server_group_members': -1, + 'security_groups': 10, + 'security_group_rules': 20, + } + + return { + k: {'limit': v, 'in_use': v // 2} + for k, v in absolute_limits.items() + } + + class BaseLimitTestSuite(test.NoDBTestCase): """Base test suite which provides relevant stubs and time abstraction.""" def setUp(self): - super(BaseLimitTestSuite, self).setUp() + super().setUp() self.time = 0.0 - self.absolute_limits = {} - - def stub_get_project_quotas(context, project_id, usages=True): - return {k: dict(limit=v, in_use=v // 2) - for k, v in self.absolute_limits.items()} patcher_get_project_quotas = mock.patch.object( nova.quota.QUOTAS, "get_project_quotas", - side_effect=stub_get_project_quotas) + side_effect=fake_get_project_quotas) self.mock_get_project_quotas = patcher_get_project_quotas.start() self.addCleanup(patcher_get_project_quotas.stop) patcher = self.mock_can = mock.patch('nova.context.RequestContext.can') @@ -94,19 +112,6 @@ class LimitsControllerTestV21(BaseLimitTestSuite): request.environ["nova.context"] = context return request - def test_empty_index_json(self): - # Test getting empty limit details in JSON. - request = self._get_index_request() - response = request.get_response(self.controller) - expected = { - "limits": { - "rate": [], - "absolute": {}, - }, - } - body = jsonutils.loads(response.body) - self.assertEqual(expected, body) - def test_index_json(self): self._test_index_json() @@ -120,41 +125,33 @@ class LimitsControllerTestV21(BaseLimitTestSuite): if tenant_id is None: tenant_id = context.project_id - self.absolute_limits = { - 'ram': 512, - 'instances': 5, - 'cores': 21, - 'key_pairs': 10, - 'floating_ips': 10, - 'security_groups': 10, - 'security_group_rules': 20, - } expected = { "limits": { - "rate": [], "absolute": { - "maxTotalRAMSize": 512, - "maxTotalInstances": 5, - "maxTotalCores": 21, - "maxTotalKeypairs": 10, - "maxTotalFloatingIps": 10, + "maxImageMeta": -1, + "maxPersonality": -1, + "maxPersonalitySize": -1, "maxSecurityGroups": 10, "maxSecurityGroupRules": 20, - "totalRAMUsed": 256, - "totalCoresUsed": 10, - "totalInstancesUsed": 2, + "maxServerGroups": 10, + "maxServerGroupMembers": -1, + "maxServerMeta": -1, + "maxTotalCores": -1, + "maxTotalFloatingIps": 10, + "maxTotalInstances": 5, + "maxTotalKeypairs": -1, + "maxTotalRAMSize": 512, + "totalCoresUsed": -1, "totalFloatingIpsUsed": 5, + "totalInstancesUsed": 2, + "totalRAMUsed": 256, "totalSecurityGroupsUsed": 5, - }, + "totalServerGroupsUsed": 5, + }, + "rate": [], }, } - def _get_project_quotas(context, project_id, usages=True): - return {k: dict(limit=v, in_use=v // 2) - for k, v in self.absolute_limits.items()} - - self.mock_get_project_quotas.side_effect = _get_project_quotas - response = request.get_response(self.controller) body = jsonutils.loads(response.body) @@ -162,43 +159,6 @@ class LimitsControllerTestV21(BaseLimitTestSuite): self.mock_get_project_quotas.assert_called_once_with( context, tenant_id, usages=True) - def _do_test_used_limits(self, reserved): - request = self._get_index_request(tenant_id=None) - quota_map = { - 'totalRAMUsed': 'ram', - 'totalCoresUsed': 'cores', - 'totalInstancesUsed': 'instances', - 'totalFloatingIpsUsed': 'floating_ips', - 'totalSecurityGroupsUsed': 'security_groups', - 'totalServerGroupsUsed': 'server_groups', - } - limits = {} - expected_abs_limits = [] - for display_name, q in quota_map.items(): - limits[q] = {'limit': len(display_name), - 'in_use': len(display_name) // 2, - 'reserved': 0} - expected_abs_limits.append(display_name) - - def stub_get_project_quotas(context, project_id, usages=True): - return limits - - self.mock_get_project_quotas.side_effect = stub_get_project_quotas - - res = request.get_response(self.controller) - body = jsonutils.loads(res.body) - abs_limits = body['limits']['absolute'] - for limit in expected_abs_limits: - value = abs_limits[limit] - r = limits[quota_map[limit]]['reserved'] if reserved else 0 - self.assertEqual(limits[quota_map[limit]]['in_use'] + r, value) - - def test_used_limits_basic(self): - self._do_test_used_limits(False) - - def test_used_limits_with_reserved(self): - self._do_test_used_limits(True) - def test_admin_can_fetch_limits_for_a_given_tenant_id(self): project_id = "123456" user_id = "A1234" @@ -214,8 +174,8 @@ class LimitsControllerTestV21(BaseLimitTestSuite): self.assertEqual(2, self.mock_can.call_count) self.mock_can.assert_called_with( l_policies.OTHER_PROJECT_LIMIT_POLICY_NAME) - self.mock_get_project_quotas.assert_called_once_with(context, - tenant_id, usages=True) + self.mock_get_project_quotas.assert_called_once_with( + context, tenant_id, usages=True) def _test_admin_can_fetch_used_limits_for_own_project(self, req_get): project_id = "123456" @@ -268,33 +228,6 @@ class LimitsControllerTestV21(BaseLimitTestSuite): self.mock_get_project_quotas.assert_called_once_with( context, project_id, usages=True) - def test_used_ram_added(self): - fake_req = self._get_index_request() - - def stub_get_project_quotas(context, project_id, usages=True): - return {'ram': {'limit': 512, 'in_use': 256}} - - self.mock_get_project_quotas.side_effect = stub_get_project_quotas - - res = fake_req.get_response(self.controller) - body = jsonutils.loads(res.body) - abs_limits = body['limits']['absolute'] - self.assertIn('totalRAMUsed', abs_limits) - self.assertEqual(256, abs_limits['totalRAMUsed']) - self.assertEqual(1, self.mock_get_project_quotas.call_count) - - def test_no_ram_quota(self): - fake_req = self._get_index_request() - - self.mock_get_project_quotas.side_effect = None - self.mock_get_project_quotas.return_value = {} - - res = fake_req.get_response(self.controller) - body = jsonutils.loads(res.body) - abs_limits = body['limits']['absolute'] - self.assertNotIn('totalRAMUsed', abs_limits) - self.assertEqual(1, self.mock_get_project_quotas.call_count) - class FakeHttplibSocket(object): """Fake `httplib.HTTPResponse` replacement.""" @@ -346,28 +279,32 @@ class LimitsViewBuilderTest(test.NoDBTestCase): super(LimitsViewBuilderTest, self).setUp() self.view_builder = views.limits.ViewBuilder() self.req = fakes.HTTPRequest.blank('/?tenant_id=None') - self.rate_limits = [] - self.absolute_limits = {"metadata_items": {'limit': 1, 'in_use': 1}, - "injected_files": {'limit': 5, 'in_use': 1}, - "injected_file_content_bytes": - {'limit': 5, 'in_use': 1}} def test_build_limits(self): - expected_limits = {"limits": { + quotas = { + "metadata_items": {'limit': 1, 'in_use': 1}, + "injected_files": {'limit': 5, 'in_use': 1}, + "injected_file_content_bytes": {'limit': 5, 'in_use': 1}, + } + expected_limits = { + "limits": { "rate": [], - "absolute": {"maxServerMeta": 1, - "maxImageMeta": 1, - "maxPersonality": 5, - "maxPersonalitySize": 5}}} + "absolute": { + "maxServerMeta": 1, + "maxImageMeta": 1, + "maxPersonality": 5, + "maxPersonalitySize": 5, + } + }, + } - output = self.view_builder.build(self.req, self.absolute_limits) + output = self.view_builder.build(self.req, quotas) self.assertThat(output, matchers.DictMatches(expected_limits)) def test_build_limits_empty_limits(self): - expected_limits = {"limits": {"rate": [], - "absolute": {}}} - quotas = {} + expected_limits = {"limits": {"rate": [], "absolute": {}}} + output = self.view_builder.build(self.req, quotas) self.assertThat(output, matchers.DictMatches(expected_limits)) @@ -381,34 +318,25 @@ class LimitsControllerTestV236(BaseLimitTestSuite): version='2.36') def test_index_filtered(self): - absolute_limits = { - 'ram': 512, - 'instances': 5, - 'cores': 21, - 'key_pairs': 10, - 'floating_ips': 10, - 'security_groups': 10, - 'security_group_rules': 20, - } - - def _get_project_quotas(context, project_id, usages=True): - return {k: dict(limit=v, in_use=v // 2) - for k, v in absolute_limits.items()} - - self.mock_get_project_quotas.side_effect = _get_project_quotas - response = self.controller.index(self.req) expected_response = { "limits": { "rate": [], "absolute": { - "maxTotalRAMSize": 512, + "maxImageMeta": -1, + "maxPersonality": -1, + "maxPersonalitySize": -1, + "maxServerGroups": 10, + "maxServerGroupMembers": -1, + "maxServerMeta": -1, + "maxTotalCores": -1, "maxTotalInstances": 5, - "maxTotalCores": 21, - "maxTotalKeypairs": 10, - "totalRAMUsed": 256, - "totalCoresUsed": 10, + "maxTotalKeypairs": -1, + "maxTotalRAMSize": 512, + "totalCoresUsed": -1, "totalInstancesUsed": 2, + "totalRAMUsed": 256, + "totalServerGroupsUsed": 5, }, }, } @@ -424,28 +352,11 @@ class LimitsControllerTestV239(BaseLimitTestSuite): version='2.39') def test_index_filtered_no_max_image_meta(self): - absolute_limits = { - "metadata_items": 1, - } - - def _get_project_quotas(context, project_id, usages=True): - return {k: dict(limit=v, in_use=v // 2) - for k, v in absolute_limits.items()} - - self.mock_get_project_quotas.side_effect = _get_project_quotas - response = self.controller.index(self.req) # starting from version 2.39 there is no 'maxImageMeta' field # in response after removing 'image-metadata' proxy API - expected_response = { - "limits": { - "rate": [], - "absolute": { - "maxServerMeta": 1, - }, - }, - } - self.assertEqual(expected_response, response) + self.assertNotIn('maxImageMeta', response['limits']['absolute']) + self.assertIn('maxServerMeta', response['limits']['absolute']) class LimitsControllerTestV275(BaseLimitTestSuite): @@ -454,23 +365,12 @@ class LimitsControllerTestV275(BaseLimitTestSuite): self.controller = limits_v21.LimitsController() def test_index_additional_query_param_old_version(self): - absolute_limits = { - "metadata_items": 1, - } - req = fakes.HTTPRequest.blank("/?unknown=fake", - version='2.74') - - def _get_project_quotas(context, project_id, usages=True): - return {k: dict(limit=v, in_use=v // 2) - for k, v in absolute_limits.items()} - - self.mock_get_project_quotas.side_effect = _get_project_quotas + req = fakes.HTTPRequest.blank("/?unknown=fake", version='2.74') self.controller.index(req) self.controller.index(req) def test_index_additional_query_param(self): - req = fakes.HTTPRequest.blank("/?unknown=fake", - version='2.75') + req = fakes.HTTPRequest.blank("/?unknown=fake", version='2.75') self.assertRaises( exception.ValidationError, self.controller.index, req=req) diff --git a/nova/tests/unit/policies/test_limits.py b/nova/tests/unit/policies/test_limits.py index c7951a552a76..87bf8e68f4ad 100644 --- a/nova/tests/unit/policies/test_limits.py +++ b/nova/tests/unit/policies/test_limits.py @@ -39,13 +39,18 @@ class LimitsPolicyTest(base.BasePolicyTest): self.req = fakes.HTTPRequest.blank('') self.absolute_limits = { - 'ram': 512, - 'instances': 5, 'cores': 21, - 'key_pairs': 10, 'floating_ips': 10, - 'security_groups': 10, + 'injected_file_content_bytes': 1024, + 'injected_files': 20, + 'instances': 5, + 'key_pairs': 10, + 'metadata_items': 10, + 'ram': 512, 'security_group_rules': 20, + 'security_groups': 10, + 'server_group_members': 5, + 'server_groups': 10, } def stub_get_project_quotas(context, project_id, usages=True):