diff --git a/releasenotes/notes/wallaby-add-ram-quota-d8e64d0385b1429f.yaml b/releasenotes/notes/wallaby-add-ram-quota-d8e64d0385b1429f.yaml new file mode 100644 index 0000000000..951d1ac3a1 --- /dev/null +++ b/releasenotes/notes/wallaby-add-ram-quota-d8e64d0385b1429f.yaml @@ -0,0 +1,7 @@ +--- +features: + - | + Added the ability to quota on total amount of RAM in MB used per project. + Set ``quota.max_ram_per_tenant`` to enable. Default is -1 (unlimited) + to be backwards compatible. Existing installations will need to manually + backfill quote usage for this to work as expected. diff --git a/trove/common/cfg.py b/trove/common/cfg.py index 6109a3598e..9d30af1720 100644 --- a/trove/common/cfg.py +++ b/trove/common/cfg.py @@ -221,6 +221,9 @@ common_opts = [ default=10, help='Default maximum number of instances per tenant.', deprecated_name='max_instances_per_user'), + cfg.IntOpt('max_ram_per_tenant', + default=-1, + help='Default maximum total amount of RAM in MB per tenant.'), cfg.IntOpt('max_accepted_volume_size', default=10, help='Default maximum volume size (in GB) for an instance.'), cfg.IntOpt('max_volumes_per_tenant', default=40, diff --git a/trove/instance/models.py b/trove/instance/models.py index 4d9396a661..ab47a82d0a 100644 --- a/trove/instance/models.py +++ b/trove/instance/models.py @@ -716,8 +716,8 @@ class BaseInstance(SimpleInstance): self.update_db(task_status=InstanceTasks.DELETING, configuration_id=None) task_api.API(self.context).delete_instance(self.id) - - deltas = {'instances': -1} + flavor = self.get_flavor() + deltas = {'instances': -1, 'ram': -flavor.ram} if self.volume_support: deltas['volumes'] = -self.volume_size return run_with_quotas(self.tenant_id, @@ -913,6 +913,9 @@ class BaseInstance(SimpleInstance): except exception.ModelNotFoundError: pass + def get_flavor(self): + return self.nova_client.flavors.get(self.flavor_id) + @property def volume_client(self): if not self._volume_client: @@ -1153,7 +1156,7 @@ class Instance(BuiltInstance): cls._validate_remote_datastore(context, region_name, flavor, datastore, datastore_version) - deltas = {'instances': 1} + deltas = {'instances': 1, 'ram': flavor.ram} if volume_support: if replica_source: try: @@ -1351,9 +1354,6 @@ class Instance(BuiltInstance): module_models.InstanceModule.create( context, instance_id, module.id, module.md5) - def get_flavor(self): - return self.nova_client.flavors.get(self.flavor_id) - def get_default_configuration_template(self): flavor = self.get_flavor() LOG.debug("Getting default config template for datastore version " @@ -1371,13 +1371,13 @@ class Instance(BuiltInstance): if self.db_info.cluster_id is not None: raise exception.ClusterInstanceOperationNotSupported() - # Validate that the old and new flavor IDs are not the same, new flavor - # can be found and has ephemeral/volume support if required by the - # current flavor. + # Validate that the old and new flavor IDs are not the same, new + # flavor can be found and has ephemeral/volume support if required + # by the current flavor. if self.flavor_id == new_flavor_id: - raise exception.BadRequest(_("The new flavor id must be different " - "than the current flavor id of '%s'.") - % self.flavor_id) + raise exception.BadRequest( + _("The new flavor id must be different " + "than the current flavor id of '%s'.") % self.flavor_id) try: new_flavor = self.nova_client.flavors.get(new_flavor_id) except nova_exceptions.NotFound: @@ -1390,13 +1390,20 @@ class Instance(BuiltInstance): elif self.device_path is not None: # ephemeral support enabled if new_flavor.ephemeral == 0: - raise exception.LocalStorageNotSpecified(flavor=new_flavor_id) + raise exception.LocalStorageNotSpecified( + flavor=new_flavor_id) - # Set the task to RESIZING and begin the async call before returning. - self.update_db(task_status=InstanceTasks.RESIZING) - LOG.debug("Instance %s set to RESIZING.", self.id) - task_api.API(self.context).resize_flavor(self.id, old_flavor, - new_flavor) + def _resize_flavor(): + # Set the task to RESIZING and begin the async call before + # returning. + self.update_db(task_status=InstanceTasks.RESIZING) + LOG.debug("Instance %s set to RESIZING.", self.id) + task_api.API(self.context).resize_flavor(self.id, old_flavor, + new_flavor) + + return run_with_quotas(self.tenant_id, + {'ram': new_flavor.ram - old_flavor.ram}, + _resize_flavor) def resize_volume(self, new_size): """Resize instance volume. diff --git a/trove/quota/models.py b/trove/quota/models.py index e2ae0979d3..03533fd07f 100644 --- a/trove/quota/models.py +++ b/trove/quota/models.py @@ -75,6 +75,7 @@ class Resource(object): """Describe a single resource for quota checking.""" INSTANCES = 'instances' + RAM = 'ram' VOLUMES = 'volumes' BACKUPS = 'backups' diff --git a/trove/quota/quota.py b/trove/quota/quota.py index fd5aa76672..56891efefa 100644 --- a/trove/quota/quota.py +++ b/trove/quota/quota.py @@ -349,6 +349,7 @@ QUOTAS = QuotaEngine() ''' Define all kind of resources here ''' resources = [Resource(Resource.INSTANCES, 'max_instances_per_tenant'), + Resource(Resource.RAM, 'max_ram_per_tenant'), Resource(Resource.BACKUPS, 'max_backups_per_tenant'), Resource(Resource.VOLUMES, 'max_volumes_per_tenant')] diff --git a/trove/tests/api/limits.py b/trove/tests/api/limits.py index 2279bdb330..217d7e01a1 100644 --- a/trove/tests/api/limits.py +++ b/trove/tests/api/limits.py @@ -39,6 +39,7 @@ DEFAULT_RATE = CONF.http_get_rate DEFAULT_MAX_VOLUMES = CONF.max_volumes_per_tenant DEFAULT_MAX_INSTANCES = CONF.max_instances_per_tenant DEFAULT_MAX_BACKUPS = CONF.max_backups_per_tenant +DEFAULT_MAX_RAM = CONF.max_ram_per_tenant def ensure_limits_are_not_faked(func): @@ -109,6 +110,7 @@ class Limits(object): assert_equal(int(abs_limits.max_instances), DEFAULT_MAX_INSTANCES) assert_equal(int(abs_limits.max_backups), DEFAULT_MAX_BACKUPS) assert_equal(int(abs_limits.max_volumes), DEFAULT_MAX_VOLUMES) + assert_equal(int(abs_limits.max_ram), DEFAULT_MAX_RAM) for k in d: assert_equal(d[k].verb, k) @@ -132,6 +134,7 @@ class Limits(object): assert_equal(int(abs_limits.max_instances), DEFAULT_MAX_INSTANCES) assert_equal(int(abs_limits.max_backups), DEFAULT_MAX_BACKUPS) assert_equal(int(abs_limits.max_volumes), DEFAULT_MAX_VOLUMES) + assert_equal(int(abs_limits.max_ram), DEFAULT_MAX_RAM) assert_equal(get.verb, "GET") assert_equal(get.unit, "MINUTE") assert_true(int(get.remaining) <= DEFAULT_RATE - 5) @@ -163,6 +166,8 @@ class Limits(object): DEFAULT_MAX_BACKUPS) assert_equal(int(abs_limits.max_volumes), DEFAULT_MAX_VOLUMES) + assert_equal(int(abs_limits.max_ram,), + DEFAULT_MAX_RAM) except exceptions.OverLimit: encountered = True diff --git a/trove/tests/unittests/api/common/test_limits.py b/trove/tests/unittests/api/common/test_limits.py index ed79639e82..73f0ae7c27 100644 --- a/trove/tests/unittests/api/common/test_limits.py +++ b/trove/tests/unittests/api/common/test_limits.py @@ -48,6 +48,7 @@ class BaseLimitTestSuite(trove_testtools.TestCase): self.context = trove_testtools.TroveTestContext(self) self.absolute_limits = {"max_instances": 55, "max_volumes": 100, + "max_ram": 200, "max_backups": 40} @@ -114,6 +115,10 @@ class LimitsControllerTest(BaseLimitTestSuite): resource="instances", hard_limit=100), + "ram": Quota(tenant_id=tenant_id, + resource="ram", + hard_limit=200), + "backups": Quota(tenant_id=tenant_id, resource="backups", hard_limit=40), @@ -135,6 +140,7 @@ class LimitsControllerTest(BaseLimitTestSuite): { 'max_instances': 100, 'max_backups': 40, + 'max_ram': 200, 'verb': 'ABSOLUTE', 'max_volumes': 55 }, @@ -798,7 +804,7 @@ class LimitsViewsTest(trove_testtools.TestCase): "resetTime": 1311272226 } ] - abs_view = {"instances": 55, "volumes": 100, "backups": 40} + abs_view = {"instances": 55, "volumes": 100, "backups": 40, 'ram': 200} view_data = views.LimitViews(abs_view, rate_limits) self.assertIsNotNone(view_data) @@ -806,6 +812,7 @@ class LimitsViewsTest(trove_testtools.TestCase): data = view_data.data() expected = {'limits': [{'max_instances': 55, 'max_backups': 40, + 'max_ram': 200, 'verb': 'ABSOLUTE', 'max_volumes': 100}, {'regex': '.*',