Add config option to limit image members

This patch adds the image_member_quota config option. This allows a deployer
to limit the number of image members allowed per image. The default value
is 128, to be consistent with other quota defaults. Users will only be able
to update an image if the result of the transaction would be under this limit.

This is for both Glance v1 and v2

Fixes bug 1252459
docImpact

Change-Id: I02f5e82ca4c4acf6cd7bc94f9b99086054a616c9
This commit is contained in:
Alex Meade 2013-11-25 01:57:24 +00:00
parent 4e7d9cdaf9
commit b13e10b5e5
17 changed files with 359 additions and 11 deletions

View File

@ -431,6 +431,9 @@ scrubber_datadir = /var/lib/glance/scrubber
# =============== Quota Options ==================================
# The maximum number of image members allowed per image
#image_member_quota = 128
# The maximum number of image properties allowed per image
#image_property_quota = 128

View File

@ -16,6 +16,7 @@
# License for the specific language governing permissions and limitations
# under the License.
from oslo.config import cfg
import webob.exc
from glance.api import policy
@ -27,6 +28,8 @@ import glance.openstack.common.log as logging
import glance.registry.client.v1.api as registry
LOG = logging.getLogger(__name__)
CONF = cfg.CONF
CONF.import_opt('image_member_quota', 'glance.common.config')
class Controller(controller.BaseController):
@ -107,6 +110,19 @@ class Controller(controller.BaseController):
"""This will cover the missing 'show' and 'create' actions"""
raise webob.exc.HTTPMethodNotAllowed()
def _enforce_image_member_quota(self, req, attempted):
if CONF.image_member_quota < 0:
# If value is negative, allow unlimited number of members
return
maximum = CONF.image_member_quota
if attempted > maximum:
msg = _("The limit has been exceeded on the number of allowed "
"image members for this image. Attempted: %(attempted)s, "
"Maximum: %(maximum)s") % locals()
raise webob.exc.HTTPRequestEntityTooLarge(explanation=msg,
request=req)
@utils.mutating
def update(self, req, image_id, id, body=None):
"""
@ -125,6 +141,10 @@ class Controller(controller.BaseController):
self._enforce(req, 'modify_member')
self._raise_404_if_image_deleted(req, image_id)
new_number_of_members = len(registry.get_image_members(req.context,
image_id)) + 1
self._enforce_image_member_quota(req, new_number_of_members)
# Figure out can_share
can_share = None
if body and 'member' in body and 'can_share' in body['member']:
@ -162,6 +182,11 @@ class Controller(controller.BaseController):
self._enforce(req, 'modify_member')
self._raise_404_if_image_deleted(req, image_id)
memberships = body.get('memberships')
if memberships:
new_number_of_members = len(body['memberships'])
self._enforce_image_member_quota(req, new_number_of_members)
try:
registry.replace_members(req.context, image_id, body)
self._update_store_acls(req, image_id)

View File

@ -74,6 +74,8 @@ class ImageMembersController(object):
raise webob.exc.HTTPForbidden(explanation=unicode(e))
except exception.Duplicate as e:
raise webob.exc.HTTPConflict(explanation=unicode(e))
except exception.ImageMemberLimitExceeded as e:
raise webob.exc.HTTPRequestEntityTooLarge(explanation=unicode(e))
@utils.mutating
def update(self, req, image_id, member_id, status):

View File

@ -44,6 +44,9 @@ common_opts = [
cfg.BoolOpt('allow_additional_image_properties', default=True,
help=_('Whether to allow users to specify image properties '
'beyond what the image schema provides')),
cfg.IntOpt('image_member_quota', default=128,
help=_('Maximum number of image members per image. '
'Negative values evaluate to unlimited.')),
cfg.IntOpt('image_property_quota', default=128,
help=_('Maximum number of properties allowed on an image. '
'Negative values evaluate to unlimited.')),

View File

@ -289,6 +289,12 @@ class ImageSizeLimitExceeded(GlanceException):
message = _("The provided image is too large.")
class ImageMemberLimitExceeded(LimitExceeded):
message = _("The limit has been exceeded on the number of allowed image "
"members for this image. Attempted: %(attempted)s, "
"Maximum: %(maximum)s")
class ImagePropertyLimitExceeded(LimitExceeded):
message = _("The limit has been exceeded on the number of allowed image "
"properties. Attempted: %(attempted)s, Maximum: %(maximum)s")

View File

@ -195,6 +195,15 @@ def image_member_find(client, image_id=None, member=None, status=None):
status=status)
@_get_client
def image_member_count(client, image_id):
"""Return the number of image members for this image
:param image_id: identifier of image entity
"""
return client.image_member_count(image_id=image_id)
@_get_client
def image_tag_set_all(client, image_id, tags):
client.image_tag_set_all(image_id=image_id, tags=tags)

View File

@ -393,6 +393,20 @@ def image_member_find(context, image_id=None, member=None, status=None):
return [copy.deepcopy(m) for m in members]
@log_call
def image_member_count(context, image_id):
"""Return the number of image members for this image
:param image_id: identifier of image entity
"""
if not image_id:
msg = _("Image id is required.")
raise exception.Invalid(msg)
members = DATA['members']
return len(filter(lambda x: x['image_id'] == image_id, members))
@log_call
def image_member_create(context, values):
member = _image_member_format(values['image_id'],

View File

@ -1079,6 +1079,24 @@ def _image_member_find(context, session, image_id=None,
return query.all()
def image_member_count(context, image_id):
"""Return the number of image members for this image
:param image_id: identifier of image entity
"""
session = _get_session()
if not image_id:
msg = _("Image id is required.")
raise exception.Invalid(msg)
query = session.query(models.ImageMember)
query = query.filter_by(deleted=False)
query = query.filter(models.ImageMember.image_id == str(image_id))
return query.count()
# pylint: disable-msg=C0111
def _can_show_deleted(context):
"""

View File

@ -58,8 +58,10 @@ class Gateway(object):
def get_image_member_factory(self, context):
image_factory = glance.domain.ImageMemberFactory()
quota_image_factory = glance.quota.ImageMemberFactoryProxy(
image_factory, context, self.db_api)
policy_member_factory = policy.ImageMemberFactoryProxy(
image_factory, context, self.policy)
quota_image_factory, context, self.policy)
authorized_image_factory = authorization.ImageMemberFactoryProxy(
policy_member_factory, context)
return authorized_image_factory

View File

@ -27,6 +27,7 @@ import glance.openstack.common.log as logging
LOG = logging.getLogger(__name__)
CONF = cfg.CONF
CONF.import_opt('image_member_quota', 'glance.common.config')
CONF.import_opt('image_property_quota', 'glance.common.config')
CONF.import_opt('image_tag_quota', 'glance.common.config')
@ -118,6 +119,35 @@ class QuotaImageTagsProxy(object):
return getattr(self.tags, name)
class ImageMemberFactoryProxy(glance.domain.proxy.ImageMembershipFactory):
def __init__(self, member_factory, context, db_api):
self.db_api = db_api
self.context = context
super(ImageMemberFactoryProxy, self).__init__(
member_factory,
image_proxy_class=ImageProxy,
image_proxy_kwargs={})
def _enforce_image_member_quota(self, image):
if CONF.image_member_quota < 0:
# If value is negative, allow unlimited number of members
return
current_member_count = self.db_api.image_member_count(self.context,
image.image_id)
attempted = current_member_count + 1
maximum = CONF.image_member_quota
if attempted > maximum:
raise exception.ImageMemberLimitExceeded(attempted=attempted,
maximum=maximum)
def new_image_member(self, image, member_id):
self._enforce_image_member_quota(image)
return super(ImageMemberFactoryProxy, self).new_image_member(image,
member_id)
class QuotaImageLocationsProxy(object):
def __init__(self, image, context, db_api):

View File

@ -312,6 +312,7 @@ class ApiServer(Server):
self.policy_file = policy_file
self.policy_default_rule = 'default'
self.property_protection_rule_format = 'roles'
self.image_member_quota = 10
self.image_property_quota = 10
self.image_tag_quota = 10
@ -375,6 +376,7 @@ lock_path = %(lock_path)s
enable_v2_api= %(enable_v2_api)s
property_protection_file = %(property_protection_file)s
property_protection_rule_format = %(property_protection_rule_format)s
image_member_quota=%(image_member_quota)s
image_property_quota=%(image_property_quota)s
image_tag_quota=%(image_tag_quota)s
[paste_deploy]

View File

@ -1097,6 +1097,34 @@ class DriverTests(object):
image_id=image_id)
_assertMemberListMatch([], output)
def test_image_member_count(self):
TENANT1 = uuidutils.generate_uuid()
self.db_api.image_member_create(self.context,
{'member': TENANT1,
'image_id': UUID1})
actual = self.db_api.image_member_count(self.context, UUID1)
self.assertEqual(actual, 1)
def test_image_member_count_invalid_image_id(self):
TENANT1 = uuidutils.generate_uuid()
self.db_api.image_member_create(self.context,
{'member': TENANT1,
'image_id': UUID1})
self.assertRaises(exception.Invalid, self.db_api.image_member_count,
self.context, None)
def test_image_member_count_empty_image_id(self):
TENANT1 = uuidutils.generate_uuid()
self.db_api.image_member_create(self.context,
{'member': TENANT1,
'image_id': UUID1})
self.assertRaises(exception.Invalid, self.db_api.image_member_count,
self.context, "")
def test_image_member_delete(self):
TENANT1 = uuidutils.generate_uuid()
# NOTE(flaper87): Update auth token, otherwise

View File

@ -72,15 +72,19 @@ class TestApi(functional.FunctionalTest):
- List image members
15. DELETE image/members/member1
- Delete image member1
16. DELETE image
16. PUT image/members
- Attempt to replace members with an overlimit amount
17. PUT image/members/member11
- Attempt to add a member while at limit
18. DELETE image
- Delete image
17. GET image/members
19. GET image/members
- List deleted image members
18. PUT image/members/member2
20. PUT image/members/member2
- Update existing member2 of deleted image
19. PUT image/members/member3
21. PUT image/members/member3
- Add member3 to deleted image
20. DELETE image/members/member2
22. DELETE image/members/member2
- Delete member2 from deleted image
"""
self.cleanup()
@ -329,21 +333,53 @@ class TestApi(functional.FunctionalTest):
response, content = http.request(path, 'DELETE')
self.assertEqual(response.status, 204)
# 16. DELETE image
# 16. Attempt to replace members with an overlimit amount
# Adding 11 image members should fail since configured limit is 10
path = ("http://%s:%d/v1/images/%s/members" %
("127.0.0.1", self.api_port, image_id))
memberships = []
for i in range(11):
member_id = "foo%d" % i
memberships.append(dict(member_id=member_id))
http = httplib2.Http()
body = json.dumps(dict(memberships=memberships))
response, content = http.request(path, 'PUT', body=body)
self.assertEqual(response.status, 413)
# 17. Attempt to add a member while at limit
# Adding an 11th member should fail since configured limit is 10
path = ("http://%s:%d/v1/images/%s/members" %
("127.0.0.1", self.api_port, image_id))
memberships = []
for i in range(10):
member_id = "foo%d" % i
memberships.append(dict(member_id=member_id))
http = httplib2.Http()
body = json.dumps(dict(memberships=memberships))
response, content = http.request(path, 'PUT', body=body)
self.assertEqual(response.status, 204)
path = ("http://%s:%d/v1/images/%s/members/fail_me" %
("127.0.0.1", self.api_port, image_id))
http = httplib2.Http()
response, content = http.request(path, 'PUT')
self.assertEqual(response.status, 413)
# 18. DELETE image
path = "http://%s:%d/v1/images/%s" % ("127.0.0.1", self.api_port,
image_id)
http = httplib2.Http()
response, content = http.request(path, 'DELETE')
self.assertEqual(response.status, 200)
# 17. Try to list members of deleted image
# 19. Try to list members of deleted image
path = ("http://%s:%d/v1/images/%s/members" %
("127.0.0.1", self.api_port, image_id))
http = httplib2.Http()
response, content = http.request(path, 'GET')
self.assertEqual(response.status, 404)
# 18. Try to update member of deleted image
# 20. Try to update member of deleted image
path = ("http://%s:%d/v1/images/%s/members" %
("127.0.0.1", self.api_port, image_id))
http = httplib2.Http()
@ -352,14 +388,14 @@ class TestApi(functional.FunctionalTest):
response, content = http.request(path, 'PUT', body=body)
self.assertEqual(response.status, 404)
# 19. Try to add member to deleted image
# 21. Try to add member to deleted image
path = ("http://%s:%d/v1/images/%s/members/chickenpattie" %
("127.0.0.1", self.api_port, image_id))
http = httplib2.Http()
response, content = http.request(path, 'PUT')
self.assertEqual(response.status, 404)
# 20. Try to delete member of deleted image
# 22. Try to delete member of deleted image
path = ("http://%s:%d/v1/images/%s/members/pattieblack" %
("127.0.0.1", self.api_port, image_id))
http = httplib2.Http()

View File

@ -2039,6 +2039,19 @@ class TestImageMembers(functional.FunctionalTest):
body = json.loads(response.text)
self.assertEqual(0, len(body['members']))
# Adding 11 image members should fail since configured limit is 10
path = self._url('/v2/images/%s/members' % image_fixture[1]['id'])
for i in range(10):
body = json.dumps({'member': uuidutils.generate_uuid()})
response = requests.post(path, headers=get_header('tenant1'),
data=body)
self.assertEqual(200, response.status_code)
body = json.dumps({'member': uuidutils.generate_uuid()})
response = requests.post(path, headers=get_header('tenant1'),
data=body)
self.assertEqual(413, response.status_code)
# Delete Image members not found for public image
path = self._url('/v2/images/%s/members/%s' % (image_fixture[0]['id'],
TENANT3))

View File

@ -422,3 +422,38 @@ class TestQuotaImageTagsProxy(test_utils.BaseTestCase):
for item in proxy:
items.remove(item)
self.assertEqual(len(items), 0)
class TestImageMemberQuotas(test_utils.BaseTestCase):
def setUp(self):
super(TestImageMemberQuotas, self).setUp()
db_api = unit_test_utils.FakeDB()
context = FakeContext()
self.image = mock.Mock()
self.base_image_member_factory = mock.Mock()
self.image_member_factory = glance.quota.ImageMemberFactoryProxy(
self.base_image_member_factory, context,
db_api)
def test_new_image_member(self):
self.config(image_member_quota=1)
self.image_member_factory.new_image_member(self.image,
'fake_id')
self.base_image_member_factory.new_image_member\
.assert_called_once_with(self.image.base, 'fake_id')
def test_new_image_member_unlimited_members(self):
self.config(image_member_quota=-1)
self.image_member_factory.new_image_member(self.image,
'fake_id')
self.base_image_member_factory.new_image_member\
.assert_called_once_with(self.image.base, 'fake_id')
def test_new_image_member_too_many_members(self):
self.config(image_member_quota=0)
self.assertRaises(exception.ImageMemberLimitExceeded,
self.image_member_factory.new_image_member,
self.image, 'fake_id')

View File

@ -2141,6 +2141,28 @@ class TestGlanceAPI(base.IsolatedUnitTest):
res = req.get_response(self.api)
self.assertEqual(res.status_int, 204)
def test_add_member_overlimit(self):
self.config(image_member_quota=0)
test_router_api = router.API(self.mapper)
self.api = test_utils.FakeAuthMiddleware(
test_router_api, is_admin=True)
req = webob.Request.blank('/images/%s/members/pattieblack' % UUID2)
req.method = 'PUT'
res = req.get_response(self.api)
self.assertEqual(res.status_int, 413)
def test_add_member_unlimited(self):
self.config(image_member_quota=-1)
test_router_api = router.API(self.mapper)
self.api = test_utils.FakeAuthMiddleware(
test_router_api, is_admin=True)
req = webob.Request.blank('/images/%s/members/pattieblack' % UUID2)
req.method = 'PUT'
res = req.get_response(self.api)
self.assertEqual(res.status_int, 204)
def test_add_member_forbidden_by_policy(self):
rules = {"modify_member": '!'}
self.set_policy_rules(rules)
@ -2226,6 +2248,87 @@ class TestGlanceAPI(base.IsolatedUnitTest):
self.assertTrue(
'Image with identifier %s has been deleted.' % UUID2 in res.body)
def test_replace_members_of_image(self):
test_router = router.API(self.mapper)
self.api = test_utils.FakeAuthMiddleware(test_router, is_admin=True)
fixture = [{'member_id': 'pattieblack', 'can_share': 'false'}]
req = webob.Request.blank('/images/%s/members' % UUID2)
req.method = 'PUT'
req.body = json.dumps(dict(memberships=fixture))
res = req.get_response(self.api)
self.assertEqual(res.status_int, 204)
req = webob.Request.blank('/images/%s/members' % UUID2)
req.method = 'GET'
res = req.get_response(self.api)
self.assertEqual(res.status_int, 200)
memb_list = json.loads(res.body)
self.assertEqual(len(memb_list), 1)
def test_replace_members_of_image_overlimit(self):
# Set image_member_quota to 1
self.config(image_member_quota=1)
test_router = router.API(self.mapper)
self.api = test_utils.FakeAuthMiddleware(test_router, is_admin=True)
# PUT an original member entry
fixture = [{'member_id': 'baz', 'can_share': False}]
req = webob.Request.blank('/images/%s/members' % UUID2)
req.method = 'PUT'
req.body = json.dumps(dict(memberships=fixture))
res = req.get_response(self.api)
self.assertEqual(res.status_int, 204)
# GET original image member list
req = webob.Request.blank('/images/%s/members' % UUID2)
req.method = 'GET'
res = req.get_response(self.api)
self.assertEqual(res.status_int, 200)
original_members = json.loads(res.body)['members']
self.assertEqual(len(original_members), 1)
# PUT 2 image members to replace existing (overlimit)
fixture = [{'member_id': 'foo1', 'can_share': False},
{'member_id': 'foo2', 'can_share': False}]
req = webob.Request.blank('/images/%s/members' % UUID2)
req.method = 'PUT'
req.body = json.dumps(dict(memberships=fixture))
res = req.get_response(self.api)
self.assertEqual(res.status_int, 413)
# GET member list
req = webob.Request.blank('/images/%s/members' % UUID2)
req.method = 'GET'
res = req.get_response(self.api)
self.assertEqual(res.status_int, 200)
# Assert the member list was not changed
memb_list = json.loads(res.body)['members']
self.assertEqual(memb_list, original_members)
def test_replace_members_of_image_unlimited(self):
self.config(image_member_quota=-1)
test_router = router.API(self.mapper)
self.api = test_utils.FakeAuthMiddleware(test_router, is_admin=True)
fixture = [{'member_id': 'foo1', 'can_share': False},
{'member_id': 'foo2', 'can_share': False}]
req = webob.Request.blank('/images/%s/members' % UUID2)
req.method = 'PUT'
req.body = json.dumps(dict(memberships=fixture))
res = req.get_response(self.api)
self.assertEqual(res.status_int, 204)
req = webob.Request.blank('/images/%s/members' % UUID2)
req.method = 'GET'
res = req.get_response(self.api)
self.assertEqual(res.status_int, 200)
memb_list = json.loads(res.body)['members']
self.assertEqual(memb_list, fixture)
def test_create_member_to_deleted_image_raises_404(self):
"""
Tests adding members to deleted image raises 404.

View File

@ -254,6 +254,25 @@ class TestImageMembersController(test_utils.BaseTestCase):
self.assertRaises(webob.exc.HTTPConflict, self.controller.create,
request, image_id=image_id, member_id=member_id)
def test_create_overlimit(self):
self.config(image_member_quota=0)
request = unit_test_utils.get_fake_request()
image_id = UUID2
member_id = TENANT3
self.assertRaises(webob.exc.HTTPRequestEntityTooLarge,
self.controller.create, request,
image_id=image_id, member_id=member_id)
def test_create_unlimited(self):
self.config(image_member_quota=-1)
request = unit_test_utils.get_fake_request()
image_id = UUID2
member_id = TENANT3
output = self.controller.create(request, image_id=image_id,
member_id=member_id)
self.assertEqual(UUID2, output.image_id)
self.assertEqual(TENANT3, output.member_id)
def test_update_done_by_member(self):
request = unit_test_utils.get_fake_request(tenant=TENANT4)
image_id = UUID2