From 3852d1ce19fb4a3eab059a856d0a7dcf89feca27 Mon Sep 17 00:00:00 2001 From: Julie Pichon Date: Wed, 21 Aug 2013 12:44:07 +0100 Subject: [PATCH] View and update Neutron project quotas Also ensure that the correct quota is displayed in the Floating IPs allocation page (Security & Access panel). Closes-Bug: #1109140 Change-Id: I30d207fbf149bfbcfefeaddf91af49082b7b1f53 --- openstack_dashboard/api/base.py | 16 ++++ openstack_dashboard/api/neutron.py | 14 +++ .../dashboards/admin/info/tests.py | 2 +- .../dashboards/admin/projects/tests.py | 89 +++++++++++++++++++ .../dashboards/admin/projects/views.py | 20 +++++ .../dashboards/admin/projects/workflows.py | 23 +++++ .../access_and_security/floating_ips/tests.py | 41 +++++++++ .../test/api_tests/base_tests.py | 44 +++++++++ openstack_dashboard/test/tests/quotas.py | 8 ++ openstack_dashboard/usage/base.py | 4 +- openstack_dashboard/usage/quotas.py | 57 ++++++++++-- 11 files changed, 309 insertions(+), 9 deletions(-) diff --git a/openstack_dashboard/api/base.py b/openstack_dashboard/api/base.py index 2d9c595644a..5cb61999add 100644 --- a/openstack_dashboard/api/base.py +++ b/openstack_dashboard/api/base.py @@ -176,6 +176,19 @@ def __setitem__(self, k, v): def __getitem__(self, index): return self.items[index] + def __add__(self, other): + '''Merge another QuotaSet into this one. Existing quotas are + not overriden.''' + if not isinstance(other, QuotaSet): + msg = "Can only add QuotaSet to QuotaSet, " \ + "but received %s instead" % type(other) + raise ValueError(msg) + + for item in other: + if self.get(item.name).limit is None: + self.items.append(item) + return self + def __len__(self): return len(self.items) @@ -186,6 +199,9 @@ def get(self, key, default=None): match = [quota for quota in self.items if quota.name == key] return match.pop() if len(match) else Quota(key, default) + def add(self, other): + return self.__add__(other) + def get_service_from_catalog(catalog, service_type): if catalog: diff --git a/openstack_dashboard/api/neutron.py b/openstack_dashboard/api/neutron.py index cb0857aba67..f8b83014a91 100644 --- a/openstack_dashboard/api/neutron.py +++ b/openstack_dashboard/api/neutron.py @@ -608,6 +608,11 @@ def tenant_quota_get(request, tenant_id): return base.QuotaSet(neutronclient(request).show_quota(tenant_id)['quota']) +def tenant_quota_update(request, tenant_id, **kwargs): + quotas = {'quota': kwargs} + return neutronclient(request).update_quota(tenant_id, quotas) + + def list_extensions(request): extensions_list = neutronclient(request).list_extensions() if 'extensions' in extensions_list: @@ -624,3 +629,12 @@ def is_extension_supported(request, extension_alias): return True else: return False + + +def is_quotas_extension_supported(request): + network_config = getattr(settings, 'OPENSTACK_NEUTRON_NETWORK', {}) + if network_config.get('enable_quotas', False) and \ + is_extension_supported(request, 'quotas'): + return True + else: + return False diff --git a/openstack_dashboard/dashboards/admin/info/tests.py b/openstack_dashboard/dashboards/admin/info/tests.py index bc931d4ef02..2fb59ff84b3 100644 --- a/openstack_dashboard/dashboards/admin/info/tests.py +++ b/openstack_dashboard/dashboards/admin/info/tests.py @@ -96,7 +96,7 @@ def test_index_with_neutron_disabled(self): api.base.is_service_enabled(IsA(http.HttpRequest), 'volume') \ .AndReturn(True) api.base.is_service_enabled(IsA(http.HttpRequest), 'network') \ - .AndReturn(False) + .MultipleTimes().AndReturn(False) api.nova.default_quota_get(IsA(http.HttpRequest), self.tenant.id).AndReturn(self.quotas.nova) diff --git a/openstack_dashboard/dashboards/admin/projects/tests.py b/openstack_dashboard/dashboards/admin/projects/tests.py index edf52353afc..962fe0a0cea 100644 --- a/openstack_dashboard/dashboards/admin/projects/tests.py +++ b/openstack_dashboard/dashboards/admin/projects/tests.py @@ -18,6 +18,7 @@ from django.core.urlresolvers import reverse # noqa from django import http +from django.test.utils import override_settings # noqa from mox import IsA # noqa @@ -84,11 +85,14 @@ def _get_workflow_fields(self, project): def _get_quota_info(self, quota): cinder_quota = self.cinder_quotas.first() + neutron_quota = self.neutron_quotas.first() quota_data = {} for field in quotas.NOVA_QUOTA_FIELDS: quota_data[field] = int(quota.get(field).limit) for field in quotas.CINDER_QUOTA_FIELDS: quota_data[field] = int(cinder_quota.get(field).limit) + for field in quotas.NEUTRON_QUOTA_FIELDS: + quota_data[field] = int(neutron_quota.get(field).limit) return quota_data def _get_workflow_data(self, project, quota): @@ -147,6 +151,8 @@ def test_add_project_get(self): res = self.client.get(url) self.assertTemplateUsed(res, views.WorkflowView.template_name) + self.assertContains(res, '', html=True) workflow = res.context['workflow'] self.assertEqual(res.context['workflow'].name, @@ -168,6 +174,52 @@ def test_add_project_get_domain(self): domain_context_name=domain.name) self.test_add_project_get() + @test.create_stubs({api.keystone: ('get_default_role', + 'user_list', + 'group_list', + 'role_list'), + api.neutron: ('is_extension_supported', + 'tenant_quota_get'), + quotas: ('get_default_quota_data',)}) + @override_settings(OPENSTACK_NEUTRON_NETWORK={'enable_quotas': True}) + def test_add_project_get_with_neutron(self): + quota = self.quotas.first() + neutron_quotas = self.neutron_quotas.first() + + quotas.get_default_quota_data(IsA(http.HttpRequest)) \ + .AndReturn(quota) + api.neutron.is_extension_supported(IsA(http.HttpRequest), 'quotas') \ + .MultipleTimes().AndReturn(True) + api.neutron.tenant_quota_get(IsA(http.HttpRequest), + tenant_id=self.tenant.id) \ + .AndReturn(neutron_quotas) + api.keystone.get_default_role(IsA(http.HttpRequest)) \ + .MultipleTimes().AndReturn(self.roles.first()) + api.keystone.user_list(IsA(http.HttpRequest), domain=None) \ + .AndReturn(self.users.list()) + api.keystone.role_list(IsA(http.HttpRequest)) \ + .AndReturn(self.roles.list()) + api.keystone.group_list(IsA(http.HttpRequest), domain=None) \ + .AndReturn(self.groups.list()) + api.keystone.role_list(IsA(http.HttpRequest)) \ + .AndReturn(self.roles.list()) + self.mox.ReplayAll() + + res = self.client.get(reverse('horizon:admin:projects:create')) + + self.assertTemplateUsed(res, views.WorkflowView.template_name) + self.assertContains(res, '', html=True) + + workflow = res.context['workflow'] + self.assertEqual(res.context['workflow'].name, + workflows.CreateProject.name) + + step = workflow.get_step("createprojectinfoaction") + self.assertEqual(step.action.initial['ram'], quota.get('ram').limit) + self.assertEqual(step.action.initial['subnet'], + neutron_quotas.get('subnet').limit) + @test.create_stubs({api.keystone: ('get_default_role', 'add_tenant_user_role', 'tenant_create', @@ -250,6 +302,21 @@ def test_add_project_post_domain(self): domain_context_name=domain.name) self.test_add_project_post() + @test.create_stubs({api.neutron: ('is_extension_supported', + 'tenant_quota_update')}) + @override_settings(OPENSTACK_NEUTRON_NETWORK={'enable_quotas': True}) + def test_add_project_post_with_neutron(self): + quota_data = self.neutron_quotas.first() + neutron_updated_quota = dict([(key, quota_data.get(key).limit) + for key in quotas.NEUTRON_QUOTA_FIELDS]) + + api.neutron.is_extension_supported(IsA(http.HttpRequest), 'quotas') \ + .MultipleTimes().AndReturn(True) + api.neutron.tenant_quota_update(IsA(http.HttpRequest), + self.tenant.id, + **neutron_updated_quota) + self.test_add_project_post() + @test.create_stubs({api.keystone: ('user_list', 'role_list', 'group_list', @@ -539,11 +606,14 @@ def test_add_project_missing_field_error_domain(self): class UpdateProjectWorkflowTests(test.BaseAdminViewTests): def _get_quota_info(self, quota): cinder_quota = self.cinder_quotas.first() + neutron_quota = self.neutron_quotas.first() quota_data = {} for field in quotas.NOVA_QUOTA_FIELDS: quota_data[field] = int(quota.get(field).limit) for field in quotas.CINDER_QUOTA_FIELDS: quota_data[field] = int(cinder_quota.get(field).limit) + for field in quotas.NEUTRON_QUOTA_FIELDS: + quota_data[field] = int(neutron_quota.get(field).limit) return quota_data def _get_domain_id(self): @@ -845,6 +915,25 @@ def test_update_project_save_domain(self): domain_context_name=domain.name) self.test_update_project_save() + @test.create_stubs({api.neutron: ('is_extension_supported', + 'tenant_quota_get', + 'tenant_quota_update')}) + @override_settings(OPENSTACK_NEUTRON_NETWORK={'enable_quotas': True}) + def test_update_project_save_with_neutron(self): + quota_data = self.neutron_quotas.first() + neutron_updated_quota = dict([(key, quota_data.get(key).limit) + for key in quotas.NEUTRON_QUOTA_FIELDS]) + + api.neutron.is_extension_supported(IsA(http.HttpRequest), 'quotas') \ + .MultipleTimes().AndReturn(True) + api.neutron.tenant_quota_get(IsA(http.HttpRequest), + tenant_id=self.tenant.id) \ + .AndReturn(quota_data) + api.neutron.tenant_quota_update(IsA(http.HttpRequest), + self.tenant.id, + **neutron_updated_quota) + self.test_update_project_save() + @test.create_stubs({api.keystone: ('tenant_get',)}) def test_update_project_get_error(self): diff --git a/openstack_dashboard/dashboards/admin/projects/views.py b/openstack_dashboard/dashboards/admin/projects/views.py index 5e893783b3b..2d2327d484f 100644 --- a/openstack_dashboard/dashboards/admin/projects/views.py +++ b/openstack_dashboard/dashboards/admin/projects/views.py @@ -109,6 +109,22 @@ def get_initial(self): # get initial quota defaults try: quota_defaults = quotas.get_default_quota_data(self.request) + + try: + if api.base.is_service_enabled(self.request, 'network') and \ + api.neutron.is_quotas_extension_supported(self.request): + # TODO(jpichon): There is no API to access the Neutron + # default quotas (LP#1204956). For now, use the values + # from the current project. + project_id = self.request.user.project_id + quota_defaults += api.neutron.tenant_quota_get( + self.request, + tenant_id=project_id) + except Exception: + error_msg = _('Unable to retrieve default Neutron quota ' + 'values.') + self.add_error_to_step(error_msg, 'update_quotas') + for field in quotas.QUOTA_FIELDS: initial[field] = quota_defaults.get(field).limit @@ -138,6 +154,10 @@ def get_initial(self): # get initial project quota quota_data = quotas.get_tenant_quota_data(self.request, tenant_id=project_id) + if api.base.is_service_enabled(self.request, 'network') and \ + api.neutron.is_quotas_extension_supported(self.request): + quota_data += api.neutron.tenant_quota_get(self.request, + tenant_id=project_id) for field in quotas.QUOTA_FIELDS: initial[field] = quota_data.get(field).limit except Exception: diff --git a/openstack_dashboard/dashboards/admin/projects/workflows.py b/openstack_dashboard/dashboards/admin/projects/workflows.py index f1dcb650706..7c479e1f031 100644 --- a/openstack_dashboard/dashboards/admin/projects/workflows.py +++ b/openstack_dashboard/dashboards/admin/projects/workflows.py @@ -63,6 +63,13 @@ class UpdateProjectQuotaAction(workflows.Action): security_group_rules = forms.IntegerField(min_value=-1, label=_("Security Group Rules")) + # Neutron + floatingip = forms.IntegerField(min_value=-1, label=_("Floating IPs")) + network = forms.IntegerField(min_value=-1, label=_("Networks")) + port = forms.IntegerField(min_value=-1, label=_("Ports")) + router = forms.IntegerField(min_value=-1, label=_("Routers")) + subnet = forms.IntegerField(min_value=-1, label=_("Subnets")) + def __init__(self, request, *args, **kwargs): super(UpdateProjectQuotaAction, self).__init__(request, *args, @@ -418,6 +425,14 @@ def handle(self, request, data): cinder.tenant_quota_update(request, project_id, **cinder_data) + + if api.base.is_service_enabled(request, 'network') and \ + api.neutron.is_quotas_extension_supported(request): + neutron_data = dict([(key, data[key]) for key in + quotas.NEUTRON_QUOTA_FIELDS]) + api.neutron.tenant_quota_update(request, + project_id, + **neutron_data) except Exception: exceptions.handle(request, _('Unable to set project quotas.')) return True @@ -667,6 +682,14 @@ def handle(self, request, data): cinder.tenant_quota_update(request, project_id, **cinder_data) + + if api.base.is_service_enabled(request, 'network') and \ + api.neutron.is_quotas_extension_supported(request): + neutron_data = dict([(key, data[key]) for key in + quotas.NEUTRON_QUOTA_FIELDS]) + api.neutron.tenant_quota_update(request, + project_id, + **neutron_data) return True except Exception: exceptions.handle(request, _('Modified project information and ' diff --git a/openstack_dashboard/dashboards/project/access_and_security/floating_ips/tests.py b/openstack_dashboard/dashboards/project/access_and_security/floating_ips/tests.py index af79fed440a..66e47123f66 100644 --- a/openstack_dashboard/dashboards/project/access_and_security/floating_ips/tests.py +++ b/openstack_dashboard/dashboards/project/access_and_security/floating_ips/tests.py @@ -21,6 +21,7 @@ from django.core.urlresolvers import reverse # noqa from django import http +from django.test.utils import override_settings # noqa from mox import IsA # noqa @@ -175,3 +176,43 @@ def setUp(self): def tearDown(self): self.floating_ips = self._floating_ips_orig super(FloatingIpViewTests, self).tearDown() + + @test.create_stubs({api.nova: ('tenant_quota_get', 'flavor_list', + 'server_list'), + api.cinder: ('tenant_quota_get', 'volume_list', + 'volume_snapshot_list',), + api.network: ('floating_ip_pools_list', + 'tenant_floating_ip_list'), + api.neutron: ('is_extension_supported', + 'tenant_quota_get')}) + @override_settings(OPENSTACK_NEUTRON_NETWORK={'enable_quotas': True}) + def test_correct_quotas_displayed(self): + servers = [s for s in self.servers.list() + if s.tenant_id == self.request.user.tenant_id] + + api.nova.tenant_quota_get(IsA(http.HttpRequest), '1') \ + .AndReturn(self.quotas.first()) + api.nova.flavor_list(IsA(http.HttpRequest)) \ + .AndReturn(self.flavors.list()) + api.nova.server_list(IsA(http.HttpRequest)) \ + .AndReturn([servers, False]) + api.cinder.volume_list(IsA(http.HttpRequest)) \ + .AndReturn(self.volumes.list()) + api.cinder.volume_snapshot_list(IsA(http.HttpRequest)) \ + .AndReturn(self.snapshots.list()) + api.cinder.tenant_quota_get(IsA(http.HttpRequest), '1') \ + .AndReturn(self.cinder_quotas.first()) + api.neutron.is_extension_supported(IsA(http.HttpRequest), 'quotas') \ + .AndReturn(True) + api.neutron.tenant_quota_get(IsA(http.HttpRequest), self.tenant.id) \ + .AndReturn(self.neutron_quotas.first()) + api.network.tenant_floating_ip_list(IsA(http.HttpRequest)) \ + .MultipleTimes().AndReturn(self.floating_ips.list()) + api.network.floating_ip_pools_list(IsA(http.HttpRequest)) \ + .AndReturn(self.pools.list()) + self.mox.ReplayAll() + + url = reverse('%s:allocate' % NAMESPACE) + res = self.client.get(url) + self.assertEqual(res.context['usages']['floating_ips']['quota'], + self.neutron_quotas.first().get('floatingip').limit) diff --git a/openstack_dashboard/test/api_tests/base_tests.py b/openstack_dashboard/test/api_tests/base_tests.py index d488757c6e5..ccf8966b71a 100644 --- a/openstack_dashboard/test/api_tests/base_tests.py +++ b/openstack_dashboard/test/api_tests/base_tests.py @@ -169,3 +169,47 @@ def test_url_for(self): self.request.user.services_region = "bogus_value" with self.assertRaises(exceptions.ServiceCatalogException): url = api_base.url_for(self.request, 'image') + + +class QuotaSetTests(test.TestCase): + + def test_quotaset_add_with_plus(self): + quota_dict = {'foo': 1, 'bar': 10} + other_quota_dict = {'my_test': 12} + quota_set = api_base.QuotaSet(quota_dict) + other_quota_set = api_base.QuotaSet(other_quota_dict) + + quota_set += other_quota_set + self.assertEqual(len(quota_set), 3) + + quota_dict.update(other_quota_dict) + for q in quota_set: + self.assertEqual(q.limit, quota_dict[q.name]) + + def test_quotaset_add_doesnt_override_existing_quota(self): + quota_dict = {'foo': 1, 'bar': 10} + quota_set = api_base.QuotaSet(quota_dict) + other_quota_set = api_base.QuotaSet({'foo': 12}) + + quota_set += other_quota_set + self.assertEqual(len(quota_set), 2) + + for q in quota_set: + self.assertEqual(q.limit, quota_dict[q.name]) + + def test_quotaset_add_method(self): + quota_dict = {'foo': 1, 'bar': 10} + other_quota_dict = {'my_test': 12} + quota_set = api_base.QuotaSet(quota_dict) + other_quota_set = api_base.QuotaSet(other_quota_dict) + + quota_set.add(other_quota_set) + self.assertEqual(len(quota_set), 3) + + quota_dict.update(other_quota_dict) + for q in quota_set: + self.assertEqual(q.limit, quota_dict[q.name]) + + def test_quotaset_add_with_wrong_type(self): + quota_set = api_base.QuotaSet({'foo': 1, 'bar': 10}) + self.assertRaises(ValueError, quota_set.add, {'test': 7}) diff --git a/openstack_dashboard/test/tests/quotas.py b/openstack_dashboard/test/tests/quotas.py index eb286cf5c35..43d6b9b5e36 100644 --- a/openstack_dashboard/test/tests/quotas.py +++ b/openstack_dashboard/test/tests/quotas.py @@ -64,6 +64,8 @@ def test_tenant_quota_usages(self): api.base.is_service_enabled(IsA(http.HttpRequest), 'volume').AndReturn(True) + api.base.is_service_enabled(IsA(http.HttpRequest), + 'network').AndReturn(False) api.nova.flavor_list(IsA(http.HttpRequest)) \ .AndReturn(self.flavors.list()) api.nova.tenant_quota_get(IsA(http.HttpRequest), '1') \ @@ -98,6 +100,8 @@ def test_tenant_quota_usages_without_volume(self): api.base.is_service_enabled(IsA(http.HttpRequest), 'volume').AndReturn(False) + api.base.is_service_enabled(IsA(http.HttpRequest), + 'network').AndReturn(False) api.nova.flavor_list(IsA(http.HttpRequest)) \ .AndReturn(self.flavors.list()) api.nova.tenant_quota_get(IsA(http.HttpRequest), '1') \ @@ -123,6 +127,8 @@ def test_tenant_quota_usages_without_volume(self): def test_tenant_quota_usages_no_instances_running(self): api.base.is_service_enabled(IsA(http.HttpRequest), 'volume').AndReturn(False) + api.base.is_service_enabled(IsA(http.HttpRequest), + 'network').AndReturn(False) api.nova.flavor_list(IsA(http.HttpRequest)) \ .AndReturn(self.flavors.list()) api.nova.tenant_quota_get(IsA(http.HttpRequest), '1') \ @@ -160,6 +166,8 @@ def test_tenant_quota_usages_unlimited_quota(self): api.base.is_service_enabled(IsA(http.HttpRequest), 'volume').AndReturn(True) + api.base.is_service_enabled(IsA(http.HttpRequest), + 'network').AndReturn(False) api.nova.flavor_list(IsA(http.HttpRequest)) \ .AndReturn(self.flavors.list()) api.nova.tenant_quota_get(IsA(http.HttpRequest), '1') \ diff --git a/openstack_dashboard/usage/base.py b/openstack_dashboard/usage/base.py index 5b1c91795dd..c18c3b36654 100644 --- a/openstack_dashboard/usage/base.py +++ b/openstack_dashboard/usage/base.py @@ -120,9 +120,7 @@ def get_neutron_limits(self): # Quotas are an optional extension in Neutron. If it isn't # enabled, assume the floating IP limit is infinite. - network_config = getattr(settings, 'OPENSTACK_NEUTRON_NETWORK', {}) - if not network_config.get('enable_quotas', False) or \ - not api.neutron.is_extension_supported(self.request, 'quotas'): + if not api.neutron.is_quotas_extension_supported(self.request): self.limits['maxTotalFloatingIps'] = float("inf") return diff --git a/openstack_dashboard/usage/quotas.py b/openstack_dashboard/usage/quotas.py index 6de290b3c79..611f54c2bce 100644 --- a/openstack_dashboard/usage/quotas.py +++ b/openstack_dashboard/usage/quotas.py @@ -1,5 +1,6 @@ from collections import defaultdict # noqa import itertools +import logging from horizon import exceptions from horizon.utils.memoized import memoized # noqa @@ -7,8 +8,13 @@ from openstack_dashboard.api import base from openstack_dashboard.api import cinder from openstack_dashboard.api import network +from openstack_dashboard.api import neutron from openstack_dashboard.api import nova + +LOG = logging.getLogger(__name__) + + NOVA_QUOTA_FIELDS = ("metadata_items", "cores", "instances", @@ -24,7 +30,13 @@ "snapshots", "gigabytes",) -QUOTA_FIELDS = NOVA_QUOTA_FIELDS + CINDER_QUOTA_FIELDS +NEUTRON_QUOTA_FIELDS = ("network", + "subnet", + "port", + "router", + "floatingip",) + +QUOTA_FIELDS = NOVA_QUOTA_FIELDS + CINDER_QUOTA_FIELDS + NEUTRON_QUOTA_FIELDS class QuotaUsage(dict): @@ -96,16 +108,51 @@ def get_default_quota_data(request, disabled_quotas=None, tenant_id=None): def get_tenant_quota_data(request, disabled_quotas=None, tenant_id=None): - return _get_quota_data(request, - "tenant_quota_get", - disabled_quotas=disabled_quotas, - tenant_id=tenant_id) + qs = _get_quota_data(request, + "tenant_quota_get", + disabled_quotas=disabled_quotas, + tenant_id=tenant_id) + + # TODO(jpichon): There is no API to get the default system quotas + # in Neutron (cf. LP#1204956), so for now handle tenant quotas here. + # This should be handled in _get_quota_data() eventually. + if disabled_quotas and 'floating_ips' in disabled_quotas: + # Neutron with quota extension disabled + if 'floatingip' in disabled_quotas: + qs.add(base.QuotaSet({'floating_ips': -1})) + # Neutron with quota extension enabled + else: + tenant_id = tenant_id or request.user.tenant_id + neutron_quotas = neutron.tenant_quota_get(request, tenant_id) + # Rename floatingip to floating_ips since that's how it's + # expected in some places (e.g. Security & Access' Floating IPs) + fips_quota = neutron_quotas.get('floatingip').limit + qs.add(base.QuotaSet({'floating_ips': fips_quota})) + + return qs def get_disabled_quotas(request): disabled_quotas = [] + + # Cinder if not base.is_service_enabled(request, 'volume'): disabled_quotas.extend(CINDER_QUOTA_FIELDS) + + # Neutron + if not base.is_service_enabled(request, 'network'): + disabled_quotas.extend(NEUTRON_QUOTA_FIELDS) + else: + # Remove the nova network quotas + disabled_quotas.extend(['floating_ips', 'fixed_ips']) + + try: + if not neutron.is_quotas_extension_supported(request): + disabled_quotas.extend(NEUTRON_QUOTA_FIELDS) + except Exception: + LOG.exception("There was an error checking if the Neutron " + "quotas extension is enabled.") + return disabled_quotas