Add config option to limit image tags

This patch adds the image_tag_quota config option. This allows a deployer
to limit the number of image tags allowed on an image for v2. The default value
is 128. If an image is somehow over the limit, tags can still be removed but no
more may be added.

Fixes bug 1252337
docImpact

Change-Id: I2036e2a88601d7c5aa85fad32d90fe0ed30b84c8
This commit is contained in:
Alex Meade 2013-11-22 15:54:06 +00:00
parent 574936f650
commit 4e7d9cdaf9
11 changed files with 266 additions and 6 deletions

View File

@ -434,6 +434,9 @@ scrubber_datadir = /var/lib/glance/scrubber
# The maximum number of image properties allowed per image
#image_property_quota = 128
# The maximum number of tags allowed per image
#image_tag_quota = 128
# Set a system wide quota for every user. This value is the total number
# of bytes that a user can use across all storage systems. A value of
# 0 means unlimited.

View File

@ -48,6 +48,8 @@ class Controller(object):
raise webob.exc.HTTPNotFound(explanation=unicode(e))
except exception.Forbidden as e:
raise webob.exc.HTTPForbidden(explanation=unicode(e))
except exception.ImageTagLimitExceeded as e:
raise webob.exc.HTTPRequestEntityTooLarge(explanation=unicode(e))
@utils.mutating
def delete(self, req, image_id, tag_value):

View File

@ -63,7 +63,7 @@ class ImagesController(object):
raise webob.exc.HTTPBadRequest(explanation=unicode(dup))
except exception.Forbidden as e:
raise webob.exc.HTTPForbidden(explanation=unicode(e))
except exception.ImagePropertyLimitExceeded as e:
except exception.LimitExceeded as e:
LOG.info(unicode(e))
raise webob.exc.HTTPRequestEntityTooLarge(
explanation=unicode(e), request=req, content_type='text/plain')
@ -134,7 +134,7 @@ class ImagesController(object):
LOG.info(msg)
raise webob.exc.HTTPRequestEntityTooLarge(
explanation=msg, request=req, content_type='text/plain')
except exception.ImagePropertyLimitExceeded as e:
except exception.LimitExceeded as e:
LOG.info(unicode(e))
raise webob.exc.HTTPRequestEntityTooLarge(
explanation=unicode(e), request=req, content_type='text/plain')

View File

@ -47,6 +47,9 @@ common_opts = [
cfg.IntOpt('image_property_quota', default=128,
help=_('Maximum number of properties allowed on an image. '
'Negative values evaluate to unlimited.')),
cfg.IntOpt('image_tag_quota', default=128,
help=_('Maximum number of tags allowed on an image. '
'Negative values evaluate to unlimited.')),
cfg.StrOpt('data_api', default='glance.db.sqlalchemy.api',
help=_('Python module path of data access API')),
cfg.IntOpt('limit_param_default', default=25,

View File

@ -289,11 +289,16 @@ class ImageSizeLimitExceeded(GlanceException):
message = _("The provided image is too large.")
class ImagePropertyLimitExceeded(GlanceException):
class ImagePropertyLimitExceeded(LimitExceeded):
message = _("The limit has been exceeded on the number of allowed image "
"properties. Attempted: %(attempted)s, Maximum: %(maximum)s")
class ImageTagLimitExceeded(LimitExceeded):
message = _("The limit has been exceeded on the number of allowed image "
"tags. Attempted: %(attempted)s, Maximum: %(maximum)s")
class RPCError(GlanceException):
message = _("%(cls)s exception was raised in the last rpc call: %(val)s")

View File

@ -28,6 +28,20 @@ import glance.openstack.common.log as logging
LOG = logging.getLogger(__name__)
CONF = cfg.CONF
CONF.import_opt('image_property_quota', 'glance.common.config')
CONF.import_opt('image_tag_quota', 'glance.common.config')
def _enforce_image_tag_quota(tags):
if CONF.image_tag_quota < 0:
# If value is negative, allow unlimited number of tags
return
if not tags:
return
if len(tags) > CONF.image_tag_quota:
raise exception.ImageTagLimitExceeded(attempted=len(tags),
maximum=CONF.image_tag_quota)
class ImageRepoProxy(glance.domain.proxy.Repo):
@ -67,6 +81,42 @@ class ImageFactoryProxy(glance.domain.proxy.ImageFactory):
proxy_class=ImageProxy,
proxy_kwargs=proxy_kwargs)
def new_image(self, **kwargs):
tags = kwargs.pop('tags', set([]))
_enforce_image_tag_quota(tags)
return super(ImageFactoryProxy, self).new_image(tags=tags, **kwargs)
class QuotaImageTagsProxy(object):
def __init__(self, orig_set):
if orig_set is None:
orig_set = set([])
self.tags = orig_set
def add(self, item):
self.tags.add(item)
_enforce_image_tag_quota(self.tags)
def __cast__(self, *args, **kwargs):
return self.tags.__cast__(*args, **kwargs)
def __contains__(self, *args, **kwargs):
return self.tags.__contains__(*args, **kwargs)
def __eq__(self, other):
return self.tags == other
def __iter__(self, *args, **kwargs):
return self.tags.__iter__(*args, **kwargs)
def __len__(self, *args, **kwargs):
return self.tags.__len__(*args, **kwargs)
def __getattr__(self, name):
return getattr(self.tags, name)
class QuotaImageLocationsProxy(object):
@ -181,6 +231,15 @@ class ImageProxy(glance.domain.proxy.Image):
location, self.context, self.image.image_id)
raise
@property
def tags(self):
return QuotaImageTagsProxy(self.image.tags)
@tags.setter
def tags(self, value):
_enforce_image_tag_quota(value)
self.image.tags = value
@property
def locations(self):
return QuotaImageLocationsProxy(self.image,

View File

@ -313,6 +313,7 @@ class ApiServer(Server):
self.policy_default_rule = 'default'
self.property_protection_rule_format = 'roles'
self.image_property_quota = 10
self.image_tag_quota = 10
self.needs_database = True
default_sql_connection = 'sqlite:////%s/tests.sqlite' % self.test_dir
@ -375,6 +376,7 @@ enable_v2_api= %(enable_v2_api)s
property_protection_file = %(property_protection_file)s
property_protection_rule_format = %(property_protection_rule_format)s
image_property_quota=%(image_property_quota)s
image_tag_quota=%(image_tag_quota)s
[paste_deploy]
flavor = %(deployment_flavor)s
"""

View File

@ -1228,6 +1228,70 @@ class TestImages(functional.FunctionalTest):
tags = json.loads(response.text)['tags']
self.assertEqual(['sniff'], tags)
# Delete all tags
for tag in tags:
path = self._url('/v2/images/%s/tags/%s' % (image_id, tag))
response = requests.delete(path, headers=self._headers())
self.assertEqual(204, response.status_code)
# Update image with too many tags via PUT
# Configured limit is 10 tags
for i in range(10):
path = self._url('/v2/images/%s/tags/foo%i' % (image_id, i))
response = requests.put(path, headers=self._headers())
self.assertEqual(204, response.status_code)
# 11th tag should fail
path = self._url('/v2/images/%s/tags/fail_me' % image_id)
response = requests.put(path, headers=self._headers())
self.assertEqual(413, response.status_code)
# Make sure the 11th tag was not added
path = self._url('/v2/images/%s' % image_id)
response = requests.get(path, headers=self._headers())
self.assertEqual(200, response.status_code)
tags = json.loads(response.text)['tags']
self.assertEqual(10, len(tags))
# Update image tags via PATCH
path = self._url('/v2/images/%s' % image_id)
media_type = 'application/openstack-images-v2.1-json-patch'
headers = self._headers({'content-type': media_type})
doc = [
{
'op': 'replace',
'path': '/tags',
'value': ['foo'],
},
]
data = json.dumps(doc)
response = requests.patch(path, headers=headers, data=data)
self.assertEqual(200, response.status_code)
# Update image with too many tags via PATCH
# Configured limit is 10 tags
path = self._url('/v2/images/%s' % image_id)
media_type = 'application/openstack-images-v2.1-json-patch'
headers = self._headers({'content-type': media_type})
tags = ['foo%d' % i for i in range(11)]
doc = [
{
'op': 'replace',
'path': '/tags',
'value': tags,
},
]
data = json.dumps(doc)
response = requests.patch(path, headers=headers, data=data)
self.assertEqual(413, response.status_code)
# Tags should not have changed since request was over limit
path = self._url('/v2/images/%s' % image_id)
response = requests.get(path, headers=self._headers())
self.assertEqual(200, response.status_code)
tags = json.loads(response.text)['tags']
self.assertEqual(['foo'], tags)
# Update image with duplicate tag - it should be ignored
path = self._url('/v2/images/%s' % image_id)
media_type = 'application/openstack-images-v2.1-json-patch'

View File

@ -67,6 +67,7 @@ class FakeImage(object):
size = None
image_id = 'someid'
locations = [{'url': 'file:///not/a/path', 'metadata': {}}]
tags = set([])
def set_data(self, data, size=None):
self.size = 0
@ -269,6 +270,7 @@ class TestImagePropertyQuotas(test_utils.BaseTestCase):
mock.Mock())
self.image_repo_mock = mock.Mock()
self.image_repo_proxy = glance.quota.ImageRepoProxy(
self.image_repo_mock,
mock.Mock(),
@ -321,3 +323,102 @@ class TestImagePropertyQuotas(test_utils.BaseTestCase):
self.image_repo_proxy.add(self.image)
self.image_repo_mock.add.assert_called_once_with(self.base_image)
class TestImageTagQuotas(test_utils.BaseTestCase):
def setUp(self):
super(TestImageTagQuotas, self).setUp()
self.base_image = mock.Mock()
self.base_image.tags = set([])
self.image = glance.quota.ImageProxy(self.base_image,
mock.Mock(),
mock.Mock())
self.image_repo_mock = mock.Mock()
self.image_repo_proxy = glance.quota.ImageRepoProxy(
self.image_repo_mock,
mock.Mock(),
mock.Mock())
def test_replace_image_tag(self):
self.config(image_tag_quota=1)
self.image.tags = ['foo']
self.assertEqual(len(self.image.tags), 1)
def test_replace_too_many_image_tags(self):
self.config(image_tag_quota=0)
exc = self.assertRaises(exception.ImageTagLimitExceeded,
setattr, self.image, 'tags', ['foo', 'bar'])
self.assertTrue('Attempted: 2, Maximum: 0' in str(exc))
self.assertEqual(len(self.image.tags), 0)
def test_replace_unlimited_image_tags(self):
self.config(image_tag_quota=-1)
self.image.tags = ['foo']
self.assertEqual(len(self.image.tags), 1)
def test_add_image_tag(self):
self.config(image_tag_quota=1)
self.image.tags.add('foo')
self.assertEqual(len(self.image.tags), 1)
def test_add_too_many_image_tags(self):
self.config(image_tag_quota=1)
self.image.tags.add('foo')
exc = self.assertRaises(exception.ImageTagLimitExceeded,
self.image.tags.add, 'bar')
self.assertTrue('Attempted: 2, Maximum: 1' in str(exc))
def test_add_unlimited_image_tags(self):
self.config(image_tag_quota=-1)
self.image.tags.add('foo')
self.assertEqual(len(self.image.tags), 1)
def test_remove_image_tag_while_over_quota(self):
self.config(image_tag_quota=1)
self.image.tags.add('foo')
self.assertEqual(len(self.image.tags), 1)
self.config(image_tag_quota=0)
self.image.tags.remove('foo')
self.assertEqual(len(self.image.tags), 0)
class TestQuotaImageTagsProxy(test_utils.BaseTestCase):
def setUp(self):
super(TestQuotaImageTagsProxy, self).setUp()
def test_add(self):
proxy = glance.quota.QuotaImageTagsProxy(set([]))
proxy.add('foo')
self.assertTrue('foo' in proxy)
def test_add_too_many_tags(self):
self.config(image_tag_quota=0)
proxy = glance.quota.QuotaImageTagsProxy(set([]))
exc = self.assertRaises(exception.ImageTagLimitExceeded,
proxy.add, 'bar')
self.assertTrue('Attempted: 1, Maximum: 0' in str(exc))
def test_equals(self):
proxy = glance.quota.QuotaImageTagsProxy(set([]))
self.assertEqual(set([]), proxy)
def test_contains(self):
proxy = glance.quota.QuotaImageTagsProxy(set(['foo']))
self.assertTrue('foo' in proxy)
def test_len(self):
proxy = glance.quota.QuotaImageTagsProxy(set(['foo',
'bar',
'baz',
'niz']))
self.assertEqual(len(proxy), 4)
def test_iter(self):
items = set(['foo', 'bar', 'baz', 'niz'])
proxy = glance.quota.QuotaImageTagsProxy(items.copy())
self.assertEqual(len(items), 4)
for item in proxy:
items.remove(item)
self.assertEqual(len(items), 0)

View File

@ -35,6 +35,13 @@ class TestImageTagsController(base.IsolatedUnitTest):
tags = self.db.image_tag_get_all(context, unit_test_utils.UUID1)
self.assertEqual(1, len([tag for tag in tags if tag == 'dink']))
def test_create_too_many_tags(self):
self.config(image_tag_quota=0)
request = unit_test_utils.get_fake_request()
self.assertRaises(webob.exc.HTTPRequestEntityTooLarge,
self.controller.update,
request, unit_test_utils.UUID1, 'dink')
def test_create_duplicate_tag_ignored(self):
request = unit_test_utils.get_fake_request()
self.controller.update(request, unit_test_utils.UUID1, 'dink')

View File

@ -567,12 +567,23 @@ class TestImagesController(base.IsolatedUnitTest):
self.assertEqual(output_log['event_type'], 'image.create')
self.assertEqual(output_log['payload']['id'], output.image_id)
def test_create_with_too_many_tags(self):
self.config(image_tag_quota=1)
request = unit_test_utils.get_fake_request()
tags = ['ping', 'pong']
self.assertRaises(webob.exc.HTTPRequestEntityTooLarge,
self.controller.create,
request, image={}, extra_properties={},
tags=tags)
def test_update_no_changes(self):
request = unit_test_utils.get_fake_request()
output = self.controller.update(request, UUID1, changes=[])
self.assertEqual(output.image_id, UUID1)
self.assertEqual(output.created_at, output.updated_at)
self.assertEqual(output.tags, set(['ping', 'pong']))
self.assertEqual(len(output.tags), 2)
self.assertTrue('ping' in output.tags)
self.assertTrue('pong' in output.tags)
output_logs = self.notifier.get_logs()
#NOTE(markwash): don't send a notification if nothing is updated
self.assertTrue(len(output_logs) == 0)
@ -605,7 +616,9 @@ class TestImagesController(base.IsolatedUnitTest):
]
output = self.controller.update(request, UUID1, changes)
self.assertEqual(output.image_id, UUID1)
self.assertEqual(output.tags, set(['king', 'kong']))
self.assertEqual(len(output.tags), 2)
self.assertTrue('king' in output.tags)
self.assertTrue('kong' in output.tags)
self.assertNotEqual(output.created_at, output.updated_at)
def test_update_replace_property(self):
@ -1404,7 +1417,8 @@ class TestImagesController(base.IsolatedUnitTest):
{'op': 'replace', 'path': ['tags'], 'value': ['ping', 'ping']},
]
output = self.controller.update(request, UUID1, changes)
self.assertEqual(set(['ping']), output.tags)
self.assertEqual(len(output.tags), 1)
self.assertTrue('ping' in output.tags)
output_logs = self.notifier.get_logs()
self.assertEqual(len(output_logs), 1)
output_log = output_logs[0]