Read image size from request header

Image upload and image stage APIs now reads the request header
`x-openstack-image-size` and pass the image size to actual storage
bakend while uploading the image data. This change now also
set image size in the database even before image upload or
staging operation starts. As per backend implementation
if actual image size mismatches with the size proivded in
request header then the process will be aborted and HTTP 400
response will be returned to the user.

Depends-On: https://review.opendev.org/c/openstack/python-glanceclient/+/949787

Implements: blueprint set-size-on-upload

Change-Id: Ib2c282bc19d48361b16fb1b2549cfcab80dea29c
Signed-off-by: Abhishek Kekane <akekane@redhat.com>
This commit is contained in:
Abhishek Kekane
2025-05-06 06:22:48 +00:00
parent 17ad1aeaf8
commit 62a323aedd
2 changed files with 140 additions and 8 deletions

View File

@@ -278,6 +278,13 @@ class ImageDataController(object):
raise webob.exc.HTTPServiceUnavailable(explanation=msg,
request=req)
except glance_store.Invalid as e:
LOG.error(e.message)
if image.status not in ('queued', 'deleted'):
self._restore(image_repo, image)
raise webob.exc.HTTPBadRequest(
explanation=str(e))
except cursive_exception.SignatureVerificationError as e:
msg = (_LE("Signature verification failed for image %(id)s: %(e)s")
% {'id': image_id, 'e': e})
@@ -302,6 +309,8 @@ class ImageDataController(object):
@utils.mutating
def stage(self, req, image_id, data, size):
if size is None:
size = 0
try:
ks_quota.enforce_image_staging_total(req.context,
req.context.owner)
@@ -368,10 +377,11 @@ class ImageDataController(object):
ks_quota.enforce_image_count_uploading(req.context,
req.context.owner)
try:
uri, size, id, store_info = staging_store.add(
uri, image_size, id, store_info = staging_store.add(
image_id, utils.LimitingReader(
utils.CooperativeReader(data), CONF.image_size_cap), 0)
image.size = size
utils.CooperativeReader(data), CONF.image_size_cap),
size)
image.size = image_size
except glance_store.Duplicate:
msg = _("The image %s has data on staging") % image_id
raise webob.exc.HTTPConflict(explanation=msg)
@@ -394,6 +404,13 @@ class ImageDataController(object):
raise webob.exc.HTTPRequestEntityTooLarge(explanation=msg,
request=req)
except glance_store.Invalid as e:
LOG.error(e.message)
if image.status not in ('queued', 'deleted'):
self._restore(image_repo, image)
raise webob.exc.HTTPBadRequest(
explanation=str(e))
except exception.StorageQuotaFull as e:
msg = _("Image exceeds the storage quota: %s") % e
LOG.debug(msg)
@@ -456,6 +473,20 @@ class ImageDataController(object):
class RequestDeserializer(wsgi.JSONRequestDeserializer):
def _get_image_size(self, request):
try:
size = request.headers.get('x-openstack-image-size')
if size is not None:
image_size = int(size)
else:
# If header is missing, fall back to content_length or None
image_size = request.content_length or None
except (ValueError, TypeError):
# Raised if conversion to int fails or value is None
raise webob.exc.HTTPBadRequest(explanation=_(
"Invalid or missing image size in request headers."))
return image_size
def upload(self, request):
try:
request.get_content_type(('application/octet-stream',))
@@ -465,8 +496,8 @@ class RequestDeserializer(wsgi.JSONRequestDeserializer):
if self.is_valid_encoding(request) and self.is_valid_method(request):
request.is_body_readable = True
image_size = request.content_length or None
return {'size': image_size, 'data': request.body_file}
return {'size': self._get_image_size(request),
'data': request.body_file}
def stage(self, request):
if "glance-direct" not in CONF.enabled_import_methods:
@@ -480,8 +511,8 @@ class RequestDeserializer(wsgi.JSONRequestDeserializer):
if self.is_valid_encoding(request) and self.is_valid_method(request):
request.is_body_readable = True
image_size = request.content_length or None
return {'size': image_size, 'data': request.body_file}
return {'size': self._get_image_size(request),
'data': request.body_file}
class ResponseSerializer(wsgi.JSONResponseSerializer):

View File

@@ -80,6 +80,10 @@ class FakeImage(object):
def set_data(self, data, size=None, backend=None, set_active=True):
self.data = ''.join(data)
if not size:
size = len(self.data)
if size != len(self.data):
raise webob.exc.HTTPBadRequest()
self.size = size
self.status = 'modified-by-fake'
@@ -98,6 +102,9 @@ class FakeImageRepo(object):
def save(self, image, from_state=None):
self.saved_image = image
def list(self):
return []
class FakeGateway(object):
@@ -239,7 +246,21 @@ class TestImagesController(base.StoreClearingUnitTest):
self.image_repo.result = image
self.controller.upload(request, unit_test_utils.UUID2, 'YYYY', None)
self.assertEqual('YYYY', image.data)
self.assertIsNone(image.size)
self.assertEqual(4, image.size)
def test_upload_size_more_than_data(self):
request = unit_test_utils.get_fake_request(roles=['admin', 'member'])
image = FakeImage('abcd')
self.image_repo.result = image
self.assertRaises(webob.exc.HTTPBadRequest, self.controller.upload,
request, unit_test_utils.UUID2, 'YYYY', 5)
def test_upload_size_less_than_data(self):
request = unit_test_utils.get_fake_request(roles=['admin', 'member'])
image = FakeImage('abcd')
self.image_repo.result = image
self.assertRaises(webob.exc.HTTPBadRequest, self.controller.upload,
request, unit_test_utils.UUID2, 'YYYY', 2)
@mock.patch.object(glance.api.policy.Enforcer, 'enforce')
def test_upload_image_forbidden(self, mock_enforce):
@@ -520,6 +541,35 @@ class TestImagesController(base.StoreClearingUnitTest):
self.assertEqual('uploading', image.status)
self.assertEqual(4, image.size)
def test_stage_no_size(self):
image_id = str(uuid.uuid4())
request = unit_test_utils.get_fake_request(roles=['admin', 'member'])
image = FakeImage(image_id=image_id)
self.image_repo.result = image
with mock.patch.object(filesystem.Store, 'add') as mock_add:
mock_add.return_value = ('foo://bar', 4, 'ident', {})
self.controller.stage(request, image_id, 'YYYY', None)
self.assertEqual('uploading', image.status)
self.assertEqual(4, image.size)
def test_stage_size_more_than_data(self):
request = unit_test_utils.get_fake_request(roles=['admin', 'member'])
image = FakeImage('abcd')
self.image_repo.result = image
with mock.patch.object(filesystem.Store, 'add') as mock_add:
mock_add.side_effect = glance_store.Invalid()
self.assertRaises(webob.exc.HTTPBadRequest, self.controller.stage,
request, unit_test_utils.UUID2, 'YYYY', 5)
def test_stage_size_less_than_data(self):
request = unit_test_utils.get_fake_request(roles=['admin', 'member'])
image = FakeImage('abcd')
self.image_repo.result = image
with mock.patch.object(filesystem.Store, 'add') as mock_add:
mock_add.side_effect = glance_store.Invalid()
self.assertRaises(webob.exc.HTTPBadRequest, self.controller.stage,
request, unit_test_utils.UUID2, 'YYYY', 2)
def test_image_already_on_staging(self):
image_id = str(uuid.uuid4())
request = unit_test_utils.get_fake_request(roles=['admin', 'member'])
@@ -760,6 +810,57 @@ class TestImageDataDeserializer(test_utils.BaseTestCase):
self.deserializer.stage,
req)
def test_with_size_header(self):
for method in ('upload', 'stage'):
with self.subTest(method=method):
request = unit_test_utils.get_fake_request()
request.headers['Content-Type'] = 'application/octet-stream'
request.headers['x-openstack-image-size'] = 4
request.body = b'YYYY'
func = getattr(self.deserializer, method)
output = func(request)
data = output.pop('data')
self.assertEqual(b'YYYY', data.read())
expected = {'size': 4}
self.assertEqual(expected, output)
def test_without_size_header_and_content_length(self):
for method in ('upload', 'stage'):
with self.subTest(method=method):
request = unit_test_utils.get_fake_request()
request.headers['Content-Type'] = 'application/octet-stream'
request.body_file = io.StringIO('YYYY')
func = getattr(self.deserializer, method)
output = func(request)
data = output.pop('data')
self.assertEqual('YYYY', data.read())
expected = {'size': None}
self.assertEqual(expected, output)
def test_size_header_raising_type_error(self):
for method in ('upload', 'stage'):
with self.subTest(method=method):
request = unit_test_utils.get_fake_request()
request.headers['Content-Type'] = 'application/octet-stream'
request.headers['x-openstack-image-size'] = [4]
request.body = b'YYYY'
func = getattr(self.deserializer, method)
self.assertRaisesRegex(
webob.exc.HTTPBadRequest, "Invalid or missing image size",
func, request)
def test_with_invalid_size_header(self):
for method in ('upload', 'stage'):
with self.subTest(method=method):
request = unit_test_utils.get_fake_request()
request.headers['Content-Type'] = 'application/octet-stream'
request.headers['x-openstack-image-size'] = 'foobar'
request.body = b'YYYY'
func = getattr(self.deserializer, method)
self.assertRaisesRegex(
webob.exc.HTTPBadRequest, "Invalid or missing image size",
func, request)
class TestImageDataSerializer(test_utils.BaseTestCase):