From 6a53bf90d0c1ea547a1c920c45e0eeef7ddfba2e Mon Sep 17 00:00:00 2001 From: Dan Prince Date: Sun, 22 Apr 2012 19:34:38 -0400 Subject: Implement key pair quotas. Fixes LP Bug #987058. Change-Id: Ibefcdc448cb60754d5358fd08d74f7d279c8b16e --- nova/api/ec2/cloud.py | 10 +++++ nova/api/openstack/compute/contrib/keypairs.py | 6 +++ nova/db/api.py | 5 +++ nova/db/sqlalchemy/api.py | 7 ++++ nova/quota.py | 20 +++++++++- nova/tests/api/ec2/test_cloud.py | 29 ++++++++++++++ .../api/openstack/compute/contrib/test_keypairs.py | 46 ++++++++++++++++++++++ .../compute/contrib/test_quota_classes.py | 10 ++++- .../api/openstack/compute/contrib/test_quotas.py | 15 +++++-- nova/tests/test_quota.py | 22 +++++++++++ 10 files changed, 165 insertions(+), 5 deletions(-) diff --git a/nova/api/ec2/cloud.py b/nova/api/ec2/cloud.py index 8c6a1fdc3..28ed0279f 100644 --- a/nova/api/ec2/cloud.py +++ b/nova/api/ec2/cloud.py @@ -73,6 +73,11 @@ def _gen_key(context, user_id, key_name): raise exception.KeyPairExists(key_name=key_name) except exception.NotFound: pass + + if quota.allowed_key_pairs(context, 1) < 1: + msg = _("Quota exceeded, too many key pairs.") + raise exception.EC2APIError(msg) + private_key, public_key, fingerprint = crypto.generate_key_pair() key = {} key['user_id'] = user_id @@ -395,6 +400,11 @@ class CloudController(object): raise exception.KeyPairExists(key_name=key_name) except exception.NotFound: pass + + if quota.allowed_key_pairs(context, 1) < 1: + msg = _("Quota exceeded, too many key pairs.") + raise exception.EC2APIError(msg) + public_key = base64.b64decode(public_key_material) fingerprint = crypto.generate_fingerprint(public_key) key = {} diff --git a/nova/api/openstack/compute/contrib/keypairs.py b/nova/api/openstack/compute/contrib/keypairs.py index 7dbbf14f3..5a764978c 100644 --- a/nova/api/openstack/compute/contrib/keypairs.py +++ b/nova/api/openstack/compute/contrib/keypairs.py @@ -28,6 +28,7 @@ from nova.api.openstack import extensions from nova import crypto from nova import db from nova import exception +from nova import quota authorize = extensions.extension_authorizer('compute', 'keypairs') @@ -105,6 +106,11 @@ class KeypairController(object): keypair = {'user_id': context.user_id, 'name': name} + if quota.allowed_key_pairs(context, 1) < 1: + msg = _("Quota exceeded, too many key pairs.") + raise webob.exc.HTTPRequestEntityTooLarge( + explanation=msg, + headers={'Retry-After': 0}) # import if public_key is sent if 'public_key' in params: try: diff --git a/nova/db/api.py b/nova/db/api.py index 5de921667..1d4103c2d 100644 --- a/nova/db/api.py +++ b/nova/db/api.py @@ -703,6 +703,11 @@ def key_pair_get_all_by_user(context, user_id): return IMPL.key_pair_get_all_by_user(context, user_id) +def key_pair_count_by_user(context, user_id): + """Count number of key pairs for the given user ID.""" + return IMPL.key_pair_count_by_user(context, user_id) + + #################### diff --git a/nova/db/sqlalchemy/api.py b/nova/db/sqlalchemy/api.py index 1d7509aef..105bce004 100644 --- a/nova/db/sqlalchemy/api.py +++ b/nova/db/sqlalchemy/api.py @@ -1816,6 +1816,13 @@ def key_pair_get_all_by_user(context, user_id): all() +def key_pair_count_by_user(context, user_id): + authorize_user_context(context, user_id) + return model_query(context, models.KeyPair, read_deleted="no").\ + filter_by(user_id=user_id).\ + count() + + ################### diff --git a/nova/quota.py b/nova/quota.py index 9f43f722c..2fdecd1e2 100644 --- a/nova/quota.py +++ b/nova/quota.py @@ -60,6 +60,9 @@ quota_opts = [ cfg.IntOpt('quota_security_group_rules', default=20, help='number of security rules per security group'), + cfg.IntOpt('quota_key_pairs', + default=100, + help='number of key pairs per user'), ] FLAGS = flags.FLAGS @@ -68,7 +71,8 @@ FLAGS.register_opts(quota_opts) quota_resources = ['metadata_items', 'injected_file_content_bytes', 'volumes', 'gigabytes', 'ram', 'floating_ips', 'instances', - 'injected_files', 'cores', 'security_groups', 'security_group_rules'] + 'injected_files', 'cores', 'security_groups', 'security_group_rules', + 'key_pairs'] def _get_default_quotas(): @@ -85,6 +89,7 @@ def _get_default_quotas(): FLAGS.quota_injected_file_content_bytes, 'security_groups': FLAGS.quota_security_groups, 'security_group_rules': FLAGS.quota_security_group_rules, + 'key_pairs': FLAGS.quota_key_pairs, } # -1 in the quota flags means unlimited return defaults @@ -204,6 +209,19 @@ def allowed_security_group_rules(context, security_group_id, return min(requested_rules, allowed_rules) +def allowed_key_pairs(context, requested_key_pairs): + """Check quota and return min(requested, allowed) key pairs.""" + user_id = context.user_id + project_id = context.project_id + context = context.elevated() + used_key_pairs = db.key_pair_count_by_user(context, user_id) + quota = get_project_quotas(context, project_id) + allowed_key_pairs = _get_request_allotment(requested_key_pairs, + used_key_pairs, + quota['key_pairs']) + return min(requested_key_pairs, allowed_key_pairs) + + def _calculate_simple_quota(context, resource, requested): """Check quota for resource; return min(requested, allowed).""" quota = get_project_quotas(context, context.project_id) diff --git a/nova/tests/api/ec2/test_cloud.py b/nova/tests/api/ec2/test_cloud.py index 7b6fb34c2..bc7cb02e4 100644 --- a/nova/tests/api/ec2/test_cloud.py +++ b/nova/tests/api/ec2/test_cloud.py @@ -1540,6 +1540,21 @@ class CloudTestCase(test.TestCase): self.assertEqual(dummypub, keydata['public_key']) self.assertEqual(dummyfprint, keydata['fingerprint']) + def test_import_key_pair_quota_limit(self): + self.flags(quota_key_pairs=0) + pubkey_path = os.path.join(os.path.dirname(__file__), 'public_key') + f = open(pubkey_path + '/dummy.pub', 'r') + dummypub = f.readline().rstrip() + f.close + f = open(pubkey_path + '/dummy.fingerprint', 'r') + dummyfprint = f.readline().rstrip() + f.close + key_name = 'testimportkey' + public_key_material = base64.b64encode(dummypub) + self.assertRaises(exception.EC2APIError, + self.cloud.import_key_pair, self.context, key_name, + public_key_material) + def test_create_key_pair(self): good_names = ('a', 'a' * 255, string.ascii_letters + ' -_') bad_names = ('', 'a' * 256, '*', '/') @@ -1555,6 +1570,20 @@ class CloudTestCase(test.TestCase): self.context, key_name) + def test_create_key_pair_quota_limit(self): + self.flags(quota_key_pairs=10) + for i in range(0, 10): + key_name = 'key_%i' % i + result = self.cloud.create_key_pair(self.context, + key_name) + self.assertEqual(result['keyName'], key_name) + + # 11'th group should fail + self.assertRaises(exception.EC2APIError, + self.cloud.create_key_pair, + self.context, + 'foo') + def test_delete_key_pair(self): self._create_key('test') self.cloud.delete_key_pair(self.context, 'test') diff --git a/nova/tests/api/openstack/compute/contrib/test_keypairs.py b/nova/tests/api/openstack/compute/contrib/test_keypairs.py index fa962dd15..fd6fcbdb6 100644 --- a/nova/tests/api/openstack/compute/contrib/test_keypairs.py +++ b/nova/tests/api/openstack/compute/contrib/test_keypairs.py @@ -118,6 +118,22 @@ class KeypairsTest(test.TestCase): res_dict = json.loads(res.body) self.assertEqual(res.status_int, 400) + def test_keypair_create_quota_limit(self): + + def db_key_pair_count_by_user_max(self, user_id): + return 100 + + self.stubs.Set(db, "key_pair_count_by_user", + db_key_pair_count_by_user_max) + + req = webob.Request.blank('/v2/fake/os-keypairs') + req.method = 'POST' + req.headers['Content-Type'] = 'application/json' + body = {'keypair': {'name': 'foo'}} + req.body = json.dumps(body) + res = req.get_response(fakes.wsgi_app()) + self.assertEqual(res.status_int, 413) + def test_keypair_import(self): body = { 'keypair': { @@ -145,6 +161,36 @@ class KeypairsTest(test.TestCase): self.assertTrue(len(res_dict['keypair']['fingerprint']) > 0) self.assertFalse('private_key' in res_dict['keypair']) + def test_keypair_import_quota_limit(self): + + def db_key_pair_count_by_user_max(self, user_id): + return 100 + + self.stubs.Set(db, "key_pair_count_by_user", + db_key_pair_count_by_user_max) + + body = { + 'keypair': { + 'name': 'create_test', + 'public_key': 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDBYIznA' + 'x9D7118Q1VKGpXy2HDiKyUTM8XcUuhQpo0srqb9rboUp4' + 'a9NmCwpWpeElDLuva707GOUnfaBAvHBwsRXyxHJjRaI6Y' + 'Qj2oLJwqvaSaWUbyT1vtryRqy6J3TecN0WINY71f4uymi' + 'MZP0wby4bKBcYnac8KiCIlvkEl0ETjkOGUq8OyWRmn7lj' + 'j5SESEUdBP0JnuTFKddWTU/wD6wydeJaUhBTqOlHn0kX1' + 'GyqoNTE1UEhcM5ZRWgfUZfTjVyDF2kGj3vJLCJtJ8LoGc' + 'j7YaN4uPg1rBle+izwE/tLonRrds+cev8p6krSSrxWOwB' + 'bHkXa6OciiJDvkRzJXzf', + }, + } + + req = webob.Request.blank('/v2/fake/os-keypairs') + req.method = 'POST' + req.body = json.dumps(body) + req.headers['Content-Type'] = 'application/json' + res = req.get_response(fakes.wsgi_app()) + self.assertEqual(res.status_int, 413) + def test_keypair_create_duplicate(self): self.stubs.Set(db, "key_pair_get", db_key_pair_get) body = {'keypair': {'name': 'create_duplicate'}} diff --git a/nova/tests/api/openstack/compute/contrib/test_quota_classes.py b/nova/tests/api/openstack/compute/contrib/test_quota_classes.py index d922152e0..e67a8d76d 100644 --- a/nova/tests/api/openstack/compute/contrib/test_quota_classes.py +++ b/nova/tests/api/openstack/compute/contrib/test_quota_classes.py @@ -27,7 +27,8 @@ def quota_set(class_name): 'volumes': 10, 'gigabytes': 1000, 'ram': 51200, 'floating_ips': 10, 'instances': 10, 'injected_files': 5, 'cores': 20, 'injected_file_content_bytes': 10240, - 'security_groups': 10, 'security_group_rules': 20}} + 'security_groups': 10, 'security_group_rules': 20, + 'key_pairs': 100}} class QuotaClassSetsTest(test.TestCase): @@ -49,6 +50,7 @@ class QuotaClassSetsTest(test.TestCase): 'injected_file_content_bytes': 10240, 'security_groups': 10, 'security_group_rules': 20, + 'key_pairs': 100, } quota_set = self.controller._format_quota_set('test_class', @@ -67,6 +69,7 @@ class QuotaClassSetsTest(test.TestCase): self.assertEqual(qs['injected_file_content_bytes'], 10240) self.assertEqual(qs['security_groups'], 10) self.assertEqual(qs['security_group_rules'], 20) + self.assertEqual(qs['key_pairs'], 100) def test_quotas_show_as_admin(self): req = fakes.HTTPRequest.blank( @@ -90,6 +93,7 @@ class QuotaClassSetsTest(test.TestCase): 'injected_file_content_bytes': 10240, 'security_groups': 10, 'security_group_rules': 20, + 'key_pairs': 100, }} req = fakes.HTTPRequest.blank( @@ -107,6 +111,7 @@ class QuotaClassSetsTest(test.TestCase): 'injected_file_content_bytes': 10240, 'security_groups': 10, 'security_group_rules': 20, + 'key_pairs': 100, }} req = fakes.HTTPRequest.blank( @@ -134,6 +139,7 @@ class QuotaTemplateXMLSerializerTest(test.TestCase): injected_files=80, security_groups=10, security_group_rules=20, + key_pairs=100, cores=90)) text = self.serializer.serialize(exemplar) @@ -160,6 +166,7 @@ class QuotaTemplateXMLSerializerTest(test.TestCase): injected_files='80', security_groups='10', security_group_rules='20', + key_pairs='100', cores='90')) intext = ("\n" '' @@ -175,6 +182,7 @@ class QuotaTemplateXMLSerializerTest(test.TestCase): '90' '10' '20' + '100' '') result = self.deserializer.deserialize(intext)['body'] diff --git a/nova/tests/api/openstack/compute/contrib/test_quotas.py b/nova/tests/api/openstack/compute/contrib/test_quotas.py index b603ae684..2bda31595 100644 --- a/nova/tests/api/openstack/compute/contrib/test_quotas.py +++ b/nova/tests/api/openstack/compute/contrib/test_quotas.py @@ -29,7 +29,8 @@ def quota_set(id): 'gigabytes': 1000, 'ram': 51200, 'floating_ips': 10, 'instances': 10, 'injected_files': 5, 'cores': 20, 'injected_file_content_bytes': 10240, - 'security_groups': 10, 'security_group_rules': 20}} + 'security_groups': 10, 'security_group_rules': 20, + 'key_pairs': 100}} class QuotaSetsTest(test.TestCase): @@ -51,6 +52,7 @@ class QuotaSetsTest(test.TestCase): 'injected_file_content_bytes': 10240, 'security_groups': 10, 'security_group_rules': 20, + 'key_pairs': 100, } quota_set = self.controller._format_quota_set('1234', raw_quota_set) @@ -68,6 +70,7 @@ class QuotaSetsTest(test.TestCase): self.assertEqual(qs['injected_file_content_bytes'], 10240) self.assertEqual(qs['security_groups'], 10) self.assertEqual(qs['security_group_rules'], 20) + self.assertEqual(qs['key_pairs'], 100) def test_quotas_defaults(self): uri = '/v2/fake_tenant/os-quota-sets/fake_tenant/defaults' @@ -88,6 +91,7 @@ class QuotaSetsTest(test.TestCase): 'injected_file_content_bytes': 10240, 'security_groups': 10, 'security_group_rules': 20, + 'key_pairs': 100, }} self.assertEqual(res_dict, expected) @@ -111,7 +115,8 @@ class QuotaSetsTest(test.TestCase): 'metadata_items': 128, 'injected_files': 5, 'injected_file_content_bytes': 10240, 'security_groups': 10, - 'security_group_rules': 20}} + 'security_group_rules': 20, + 'key_pairs': 100}} req = fakes.HTTPRequest.blank('/v2/fake4/os-quota-sets/update_me', use_admin_context=True) @@ -126,7 +131,8 @@ class QuotaSetsTest(test.TestCase): 'metadata_items': 128, 'injected_files': 5, 'injected_file_content_bytes': 10240, 'security_groups': 10, - 'security_group_rules': 20}} + 'security_group_rules': 20, + 'key_pairs': 100}} req = fakes.HTTPRequest.blank('/v2/fake4/os-quota-sets/update_me') self.assertRaises(webob.exc.HTTPForbidden, self.controller.update, @@ -164,6 +170,7 @@ class QuotaXMLSerializerTest(test.TestCase): injected_files=80, security_groups=10, security_group_rules=20, + key_pairs=100, cores=90)) text = self.serializer.serialize(exemplar) @@ -189,6 +196,7 @@ class QuotaXMLSerializerTest(test.TestCase): injected_files='80', security_groups='10', security_group_rules='20', + key_pairs='100', cores='90')) intext = ("\n" '' @@ -203,6 +211,7 @@ class QuotaXMLSerializerTest(test.TestCase): '80' '10' '20' + '100' '90' '') diff --git a/nova/tests/test_quota.py b/nova/tests/test_quota.py index 06aaa26db..65ee7471a 100644 --- a/nova/tests/test_quota.py +++ b/nova/tests/test_quota.py @@ -43,6 +43,7 @@ class GetQuotaTestCase(test.TestCase): quota_floating_ips=10, quota_security_groups=10, quota_security_group_rules=20, + quota_key_pairs=10, quota_metadata_items=128, quota_injected_files=5, quota_injected_file_content_bytes=10 * 1024) @@ -61,6 +62,7 @@ class GetQuotaTestCase(test.TestCase): floating_ips=5, quota_security_groups=10, quota_security_group_rules=20, + quota_key_pairs=10, metadata_items=64, injected_files=2, injected_file_content_bytes=5 * 1024, @@ -84,6 +86,7 @@ class GetQuotaTestCase(test.TestCase): floating_ips=2, security_groups=5, security_group_rules=10, + key_pairs=5, metadata_items=32, injected_files=1, injected_file_content_bytes=2 * 1024, @@ -105,6 +108,7 @@ class GetQuotaTestCase(test.TestCase): floating_ips=10, security_groups=10, security_group_rules=20, + key_pairs=10, metadata_items=128, injected_files=5, injected_file_content_bytes=10 * 1024, @@ -119,6 +123,7 @@ class GetQuotaTestCase(test.TestCase): quota_floating_ips=-1, quota_security_groups=-1, quota_security_group_rules=-1, + quota_key_pairs=-1, quota_metadata_items=-1, quota_injected_files=-1, quota_injected_file_content_bytes=-1) @@ -132,6 +137,7 @@ class GetQuotaTestCase(test.TestCase): floating_ips=-1, security_groups=-1, security_group_rules=-1, + key_pairs=-1, metadata_items=-1, injected_files=-1, injected_file_content_bytes=-1, @@ -149,6 +155,7 @@ class GetQuotaTestCase(test.TestCase): floating_ips=10, security_groups=10, security_group_rules=20, + key_pairs=10, metadata_items=128, injected_files=5, injected_file_content_bytes=10 * 1024, @@ -166,6 +173,7 @@ class GetQuotaTestCase(test.TestCase): floating_ips=5, security_groups=10, security_group_rules=20, + key_pairs=10, metadata_items=64, injected_files=2, injected_file_content_bytes=5 * 1024, @@ -184,6 +192,7 @@ class GetQuotaTestCase(test.TestCase): floating_ips=10, security_groups=10, security_group_rules=20, + key_pairs=10, metadata_items=128, injected_files=5, injected_file_content_bytes=10 * 1024, @@ -202,6 +211,7 @@ class GetQuotaTestCase(test.TestCase): floating_ips=2, security_groups=5, security_group_rules=10, + key_pairs=5, metadata_items=32, injected_files=1, injected_file_content_bytes=2 * 1024, @@ -221,6 +231,7 @@ class GetQuotaTestCase(test.TestCase): floating_ips=5, security_groups=10, security_group_rules=20, + key_pairs=10, metadata_items=64, injected_files=2, injected_file_content_bytes=5 * 1024, @@ -240,6 +251,7 @@ class GetQuotaTestCase(test.TestCase): floating_ips=2, security_groups=5, security_group_rules=10, + key_pairs=5, metadata_items=32, injected_files=1, injected_file_content_bytes=2 * 1024, @@ -438,6 +450,16 @@ class QuotaTestCase(test.TestCase): security_groups = quota.allowed_security_groups(self.context, 101) self.assertEqual(security_groups, 101) + def test_unlimited_key_pairs(self): + self.flags(quota_key_pairs=10) + key_pairs = quota.allowed_key_pairs(self.context, 100) + self.assertEqual(key_pairs, 10) + db.quota_create(self.context, self.project_id, 'key_pairs', -1) + key_pairs = quota.allowed_key_pairs(self.context, 100) + self.assertEqual(key_pairs, 100) + key_pairs = quota.allowed_key_pairs(self.context, 101) + self.assertEqual(key_pairs, 101) + def test_unlimited_security_group_rules(self): def fake_security_group_rule_count_by_group(context, sec_group_id): -- cgit