From 7d86fc6d841a7c7eb2d6eb6e30fa38bc4614202d Mon Sep 17 00:00:00 2001 From: lujiefsi Date: Mon, 22 Jan 2024 22:04:25 +0800 Subject: [PATCH] Allow more options to limit number of resources This commit adds the configuration options related to resource limits in the Heat project. The `max_software_configs_per_tenant`, `max_software_deployments_per_tenant`, and `max_snapshots_per_stack` options have been added to control the maximum limits for software configs, software deployments, stack snapshots. Story: 2011006 Task: 49401 Change-Id: If33a1c6f3eb9e93f586931bc5c05104439c92bf9 --- heat/common/config.py | 13 +++++ heat/db/api.py | 29 +++++++++++ heat/engine/service.py | 34 +++++++++++++ heat/engine/service_software_config.py | 7 +++ heat/objects/snapshot.py | 4 ++ heat/objects/software_config.py | 4 ++ heat/objects/software_deployment.py | 5 ++ heat/tests/db/test_sqlalchemy_api.py | 50 +++++++++++++++++++ .../engine/service/test_software_config.py | 17 +++++++ .../engine/service/test_stack_snapshot.py | 13 +++++ .../limit-resources-aeb2f24e705840de.yaml | 26 ++++++++++ 11 files changed, 202 insertions(+) create mode 100644 releasenotes/notes/limit-resources-aeb2f24e705840de.yaml diff --git a/heat/common/config.py b/heat/common/config.py index 1e1df6f634..c6a1dfcfe4 100644 --- a/heat/common/config.py +++ b/heat/common/config.py @@ -152,6 +152,19 @@ engine_opts = [ default=512, help=_('Maximum number of stacks any one tenant may have ' 'active at one time. -1 stands for unlimited.')), + cfg.IntOpt('max_software_configs_per_tenant', + default=4096, + help=_('Maximum number of software configs any one tenant may ' + 'have active at one time. -1 stands for unlimited.')), + cfg.IntOpt('max_software_deployments_per_tenant', + default=4096, + help=_('Maximum number of software deployments any one tenant ' + 'may have active at one time.' + '-1 stands for unlimited.')), + cfg.IntOpt('max_snapshots_per_stack', + default=32, + help=_('Maximum number of snapshot any one stack may have ' + 'active at one time. -1 stands for unlimited.')), cfg.IntOpt('action_retry_limit', default=5, help=_('Number of times to retry to bring a ' diff --git a/heat/db/api.py b/heat/db/api.py index 3f81ac60f7..0d03547553 100644 --- a/heat/db/api.py +++ b/heat/db/api.py @@ -1431,6 +1431,14 @@ def software_config_get_all(context, limit=None, marker=None): limit=limit, marker=marker).all() +@context_manager.reader +def software_config_count_all(context): + query = context.session.query(models.SoftwareConfig) + if not context.is_admin: + query = query.filter_by(tenant=context.tenant_id) + return query.count() + + @context_manager.writer def software_config_delete(context, config_id): config = _software_config_get(context, config_id) @@ -1510,6 +1518,21 @@ def software_deployment_get_all(context, server_id=None): return query.all() +@context_manager.reader +def software_deployment_count_all(context): + sd = models.SoftwareDeployment + query = context.session.query(sd) + if not context.is_admin: + query = query.filter( + sqlalchemy.or_( + sd.tenant == context.tenant_id, + sd.stack_user_project_id == context.tenant_id, + ) + ) + + return query.count() + + @context_manager.writer def software_deployment_update(context, deployment_id, values): deployment = _software_deployment_get(context, deployment_id) @@ -1587,6 +1610,12 @@ def snapshot_get_all_by_stack(context, stack_id): stack_id=stack_id, tenant=context.tenant_id) +@context_manager.reader +def snapshot_count_all_by_stack(context, stack_id): + return context.session.query(models.Snapshot).filter_by( + stack_id=stack_id, tenant=context.tenant_id).count() + + # service diff --git a/heat/engine/service.py b/heat/engine/service.py index 34f9544717..037c9129bc 100644 --- a/heat/engine/service.py +++ b/heat/engine/service.py @@ -73,6 +73,10 @@ from heat.rpc import worker_api as rpc_worker_api cfg.CONF.import_opt('engine_life_check_timeout', 'heat.common.config') cfg.CONF.import_opt('max_resources_per_stack', 'heat.common.config') cfg.CONF.import_opt('max_stacks_per_tenant', 'heat.common.config') +cfg.CONF.import_opt('max_snapshots_per_stack', 'heat.common.config') +cfg.CONF.import_opt('max_software_configs_per_tenant', 'heat.common.config') +cfg.CONF.import_opt('max_software_deployments_per_tenant', + 'heat.common.config') cfg.CONF.import_opt('enable_stack_abandon', 'heat.common.config') cfg.CONF.import_opt('enable_stack_adopt', 'heat.common.config') cfg.CONF.import_opt('convergence_engine', 'heat.common.config') @@ -2124,6 +2128,17 @@ class EngineService(service.ServiceBase): raise exception.ActionInProgress(stack_name=stack.name, action=stack.action) + # Do not enforce the limit, following the stack limit + if not cnxt.is_admin: + stack_limit = cfg.CONF.max_snapshots_per_stack + count_all = snapshot_object.Snapshot.count_all_by_stack(cnxt, + stack.id) + if (stack_limit >= 0 and count_all >= stack_limit): + message = _("You have reached the maximum snapshots " + "per stack, %d. Please delete some " + "snapshots.") % stack_limit + raise exception.RequestLimitExceeded(message=message) + lock = stack_lock.StackLock(cnxt, stack.id, self.engine_id) with lock.thread_lock(): @@ -2219,6 +2234,15 @@ class EngineService(service.ServiceBase): @context.request_context def create_software_config(self, cnxt, group, name, config, inputs, outputs, options): + # Do not enforce the limit, following the stack limit + if not cnxt.is_admin: + tenant_limit = cfg.CONF.max_software_configs_per_tenant + count_all = self.software_config.count_software_config(cnxt) + if (tenant_limit >= 0 and count_all >= tenant_limit): + message = _("You have reached the maximum software configs " + "per tenant, %d. " + "Please delete some configs.") % tenant_limit + raise exception.RequestLimitExceeded(message=message) return self.software_config.create_software_config( cnxt, group=group, @@ -2257,6 +2281,16 @@ class EngineService(service.ServiceBase): input_values, action, status, status_reason, stack_user_project_id, deployment_id=None): + # Do not enforce the limit, following the stack limit + if not cnxt.is_admin: + tenant_limit = cfg.CONF.max_software_deployments_per_tenant + count_all = self.software_config.count_software_deployment(cnxt) + if (tenant_limit >= 0 and + count_all >= tenant_limit): + message = _("You have reached the maximum software " + "deployments per tenant, %d. " + "Please delete some deployments.") % tenant_limit + raise exception.RequestLimitExceeded(message=message) return self.software_config.create_software_deployment( cnxt, server_id=server_id, config_id=config_id, diff --git a/heat/engine/service_software_config.py b/heat/engine/service_software_config.py index 1c1fcb4cf2..ce5001bbb8 100644 --- a/heat/engine/service_software_config.py +++ b/heat/engine/service_software_config.py @@ -52,6 +52,9 @@ class SoftwareConfigService(object): for sc in scs] return result + def count_software_config(self, cnxt): + return software_config_object.SoftwareConfig.count_all(cnxt) + def create_software_config(self, cnxt, group, name, config, inputs, outputs, options): @@ -81,6 +84,10 @@ class SoftwareConfigService(object): result = [api.format_software_deployment(sd) for sd in all_sd] return result + def count_software_deployment(self, cnxt): + return software_deployment_object.SoftwareDeployment.count_all( + cnxt) + def metadata_software_deployments(self, cnxt, server_id): if not server_id: raise ValueError(_('server_id must be specified')) diff --git a/heat/objects/snapshot.py b/heat/objects/snapshot.py index 7e90def413..8cd53aa141 100644 --- a/heat/objects/snapshot.py +++ b/heat/objects/snapshot.py @@ -74,3 +74,7 @@ class Snapshot( return [cls._from_db_object(context, cls(), db_snapshot) for db_snapshot in db_api.snapshot_get_all_by_stack(context, stack_id)] + + @classmethod + def count_all_by_stack(cls, context, stack_id): + return db_api.snapshot_count_all_by_stack(context, stack_id) diff --git a/heat/objects/software_config.py b/heat/objects/software_config.py index e0d9d42c77..df2cafbfff 100644 --- a/heat/objects/software_config.py +++ b/heat/objects/software_config.py @@ -65,6 +65,10 @@ class SoftwareConfig( scs = db_api.software_config_get_all(context, **kwargs) return [cls._from_db_object(context, cls(), sc) for sc in scs] + @classmethod + def count_all(cls, context, **kwargs): + return db_api.software_config_count_all(context, **kwargs) + @classmethod def delete(cls, context, config_id): db_api.software_config_delete(context, config_id) diff --git a/heat/objects/software_deployment.py b/heat/objects/software_deployment.py index 564a48465b..d7cbc657af 100644 --- a/heat/objects/software_deployment.py +++ b/heat/objects/software_deployment.py @@ -77,6 +77,11 @@ class SoftwareDeployment( for db_deployment in db_api.software_deployment_get_all( context, server_id)] + @classmethod + def count_all(cls, context): + return db_api.software_deployment_count_all( + context) + @classmethod def update_by_id(cls, context, deployment_id, values): """Note this is a bit unusual as it returns the object. diff --git a/heat/tests/db/test_sqlalchemy_api.py b/heat/tests/db/test_sqlalchemy_api.py index 337190cfbd..5c79808e24 100644 --- a/heat/tests/db/test_sqlalchemy_api.py +++ b/heat/tests/db/test_sqlalchemy_api.py @@ -1121,6 +1121,13 @@ class SqlAlchemyTest(common.HeatTestCase): tenant_id='admin_tenant') self._test_software_config_get_all(get_ctx=admin_ctx) + def test_software_config_count_all(self): + self.assertEqual(0, db_api.software_config_count_all(self.ctx)) + self._create_software_config_record() + self._create_software_config_record() + self._create_software_config_record() + self.assertEqual(3, db_api.software_config_count_all(self.ctx)) + def test_software_config_delete(self): scf_id = self._create_software_config_record() @@ -1250,6 +1257,17 @@ class SqlAlchemyTest(common.HeatTestCase): deployments = db_api.software_deployment_get_all(admin_ctx) self.assertEqual(1, len(deployments)) + def test_software_deployment_count_all(self): + self.assertEqual(0, db_api.software_deployment_count_all(self.ctx)) + values = self._deployment_values() + deployment = db_api.software_deployment_create(self.ctx, values) + self.assertIsNotNone(deployment) + deployment = db_api.software_deployment_create(self.ctx, values) + self.assertIsNotNone(deployment) + deployment = db_api.software_deployment_create(self.ctx, values) + self.assertIsNotNone(deployment) + self.assertEqual(3, db_api.software_deployment_count_all(self.ctx)) + def test_software_deployment_update(self): deployment_id = str(uuid.uuid4()) err = self.assertRaises(exception.NotFound, @@ -1435,6 +1453,38 @@ class SqlAlchemyTest(common.HeatTestCase): self.assertEqual(values['status'], snapshot.status) self.assertIsNotNone(snapshot.created_at) + def test_snapshot_count_all_by_stack(self): + template = create_raw_template(self.ctx) + user_creds = create_user_creds(self.ctx) + stack1 = create_stack(self.ctx, template, user_creds) + stack2 = create_stack(self.ctx, template, user_creds) + values = [ + { + 'tenant': self.ctx.tenant_id, + 'status': 'IN_PROGRESS', + 'stack_id': stack1.id, + 'name': 'snp1' + }, + { + 'tenant': self.ctx.tenant_id, + 'status': 'IN_PROGRESS', + 'stack_id': stack1.id, + 'name': 'snp1' + }, + { + 'tenant': self.ctx.tenant_id, + 'status': 'IN_PROGRESS', + 'stack_id': stack2.id, + 'name': 'snp2' + } + ] + for val in values: + self.assertIsNotNone(db_api.snapshot_create(self.ctx, val)) + self.assertEqual(2, db_api.snapshot_count_all_by_stack(self.ctx, + stack1.id)) + self.assertEqual(1, db_api.snapshot_count_all_by_stack(self.ctx, + stack2.id)) + def create_raw_template(context, **kwargs): t = template_format.parse(wp_template) diff --git a/heat/tests/engine/service/test_software_config.py b/heat/tests/engine/service/test_software_config.py index 252d30098b..90645c3dbe 100644 --- a/heat/tests/engine/service/test_software_config.py +++ b/heat/tests/engine/service/test_software_config.py @@ -15,6 +15,7 @@ import datetime from unittest import mock import uuid +from oslo_config import cfg from oslo_messaging.rpc import dispatcher from oslo_serialization import jsonutils as json from oslo_utils import timeutils @@ -169,6 +170,14 @@ class SoftwareConfigServiceTest(common.HeatTestCase): config['outputs']) self.assertEqual(kwargs['options'], config['options']) + def test_create_config_exceeds_max_per_tenant(self): + cfg.CONF.set_override('max_software_configs_per_tenant', 0) + ex = self.assertRaises(dispatcher.ExpectedException, + self._create_software_config) + self.assertEqual(exception.RequestLimitExceeded, ex.exc_info[0]) + self.assertIn("You have reached the maximum software configs " + "per tenant", str(ex.exc_info[1])) + def test_create_software_config_structured(self): kwargs = { 'group': 'json-file', @@ -504,6 +513,14 @@ class SoftwareConfigServiceTest(common.HeatTestCase): self.assertEqual(deployment_id, deployment['id']) self.assertEqual(kwargs['input_values'], deployment['input_values']) + def test_create_deployment_exceeds_max_per_tenant(self): + cfg.CONF.set_override('max_software_deployments_per_tenant', 0) + ex = self.assertRaises(dispatcher.ExpectedException, + self._create_software_deployment) + self.assertEqual(exception.RequestLimitExceeded, ex.exc_info[0]) + self.assertIn("You have reached the maximum software deployments" + " per tenant", str(ex.exc_info[1])) + def test_create_software_deployment_invalid_stack_user_project_id(self): sc_kwargs = { 'group': 'Heat::Chef', diff --git a/heat/tests/engine/service/test_stack_snapshot.py b/heat/tests/engine/service/test_stack_snapshot.py index 57e2e26872..2d0e0b43ed 100644 --- a/heat/tests/engine/service/test_stack_snapshot.py +++ b/heat/tests/engine/service/test_stack_snapshot.py @@ -98,6 +98,19 @@ class SnapshotServiceTest(common.HeatTestCase): self.assertIsNotNone(snapshot['creation_time']) mock_load.assert_called_once_with(self.ctx, stack=mock.ANY) + @mock.patch.object(stack.Stack, 'load') + def test_create_snapshot_exceeds_max_per_stack(self, mock_load): + stk = self._create_stack('stack_snapshot_exceeds_max') + mock_load.return_value = stk + + cfg.CONF.set_override('max_snapshots_per_stack', 0) + ex = self.assertRaises(dispatcher.ExpectedException, + self.engine.stack_snapshot, + self.ctx, stk.identifier(), 'snap_none') + self.assertEqual(exception.RequestLimitExceeded, ex.exc_info[0]) + self.assertIn("You have reached the maximum snapshots per stack", + str(ex.exc_info[1])) + @mock.patch.object(stack.Stack, 'load') def test_create_snapshot_action_in_progress(self, mock_load): stack_name = 'stack_snapshot_action_in_progress' diff --git a/releasenotes/notes/limit-resources-aeb2f24e705840de.yaml b/releasenotes/notes/limit-resources-aeb2f24e705840de.yaml new file mode 100644 index 0000000000..7e9a02b6cb --- /dev/null +++ b/releasenotes/notes/limit-resources-aeb2f24e705840de.yaml @@ -0,0 +1,26 @@ +--- +features: + - | + Heat now supports limiting number of software configs, software + deployments, stack snapshots which users can create, by the following + config options. These limits are not enforced for users with admin role. + + - ``[DEFAULT] max_software_configis_per_tenant`` + - ``[DEFAULT] max_software_deployments_per_tenant`` + - ``[DEFAULT] max_snapshots_per_stack`` + +upgrade: + - | + Now the following limits are enforced by default, unless a request user + has admin role. + + - Maximum number of software configs per project is 4096 + - Maximum number of software deployments per project is 4096 + - Maximum number of stack snapshots per tenant is 32 + + Set the following options in case the limits should be increased. Limits + can be disabled by setting -1 to these options. + + - ``[DEFAULT] max_software_configis_per_tenant`` + - ``[DEFAULT] max_software_deployments_per_tenant`` + - ``[DEFAULT] max_snapshots_per_stack``