Accept Range requests and set appropriate response
Currently glance v2 API incorrectly accepts ‘Content-Range’ header for random image access and does not set response headers. As per rfc7233, ‘Range’ requests should be accepted and ‘Content-Range’ must be returned in the response headers. This patch enables Glance v2 API to accept the more appropriate ‘Range’ requests and sets ‘Content-Range’ response header. For backward compatibility with pre-Pike Glance clients, the incorrect 'Content-Range' header will be accepted silently in perpetuity. Thus this patch contains tests for 'Content-Range' in requests to prevent regressions. DocImpact Implements lite-spec I5bdadde682a0c50836bd95e2a6651d6e7e18f172 Closes-Bug: #1677391 Co-Authored-By: Hemanth Makkapati <hemanth.makkapati@rackspace.com> Change-Id: Ib7ebc792c32995751744be3f36cbc9a0c1eead2a
This commit is contained in:
parent
327682e852
commit
423f340174
@ -285,16 +285,43 @@ class RequestDeserializer(wsgi.JSONRequestDeserializer):
|
||||
class ResponseSerializer(wsgi.JSONResponseSerializer):
|
||||
|
||||
def download(self, response, image):
|
||||
|
||||
offset, chunk_size = 0, None
|
||||
# NOTE(dharinic): In case of a malformed content range,
|
||||
# NOTE(dharinic): In case of a malformed range header,
|
||||
# glance/common/wsgi.py will raise HTTPRequestRangeNotSatisfiable
|
||||
# (setting status_code to 416)
|
||||
range_val = response.request.get_content_range(image.size)
|
||||
range_val = response.request.get_range_from_request(image.size)
|
||||
|
||||
if range_val:
|
||||
# NOTE(flaper87): if not present, both, start
|
||||
# and stop, will be None.
|
||||
if range_val.start is not None and range_val.stop is not None:
|
||||
if isinstance(range_val, webob.byterange.Range):
|
||||
response_end = image.size - 1
|
||||
# NOTE(dharinic): webob parsing is zero-indexed.
|
||||
# i.e.,to download first 5 bytes of a 10 byte image,
|
||||
# request should be "bytes=0-4" and the response would be
|
||||
# "bytes 0-4/10".
|
||||
# Range if validated, will never have 'start' object as None.
|
||||
if range_val.start >= 0:
|
||||
offset = range_val.start
|
||||
else:
|
||||
# NOTE(dharinic): Negative start values needs to be
|
||||
# processed to allow suffix-length for Range request
|
||||
# like "bytes=-2" as per rfc7233.
|
||||
if abs(range_val.start) < image.size:
|
||||
offset = image.size + range_val.start
|
||||
|
||||
if range_val.end is not None and range_val.end < image.size:
|
||||
chunk_size = range_val.end - offset
|
||||
response_end = range_val.end - 1
|
||||
else:
|
||||
chunk_size = image.size - offset
|
||||
|
||||
# NOTE(dharinic): For backward compatibility reasons, we maintain
|
||||
# support for 'Content-Range' in requests even though it's not
|
||||
# correct to use it in requests.
|
||||
elif isinstance(range_val, webob.byterange.ContentRange):
|
||||
response_end = range_val.stop - 1
|
||||
# NOTE(flaper87): if not present, both, start
|
||||
# and stop, will be None.
|
||||
offset = range_val.start
|
||||
chunk_size = range_val.stop - offset
|
||||
|
||||
@ -311,7 +338,12 @@ class ResponseSerializer(wsgi.JSONResponseSerializer):
|
||||
# NOTE(dharinic): In case of a full image download, when
|
||||
# chunk_size was none, reset it to image.size to set the
|
||||
# response header's Content-Length.
|
||||
if not chunk_size:
|
||||
if chunk_size is not None:
|
||||
response.headers['Content-Range'] = 'bytes %s-%s/%s'\
|
||||
% (offset,
|
||||
response_end,
|
||||
image.size)
|
||||
else:
|
||||
chunk_size = image.size
|
||||
except glance_store.NotFound as e:
|
||||
raise webob.exc.HTTPNoContent(explanation=e.msg)
|
||||
|
@ -965,19 +965,56 @@ class Request(webob.Request):
|
||||
langs = i18n.get_available_languages('glance')
|
||||
return self.accept_language.best_match(langs)
|
||||
|
||||
def get_content_range(self, image_size):
|
||||
def get_range_from_request(self, image_size):
|
||||
"""Return the `Range` in a request."""
|
||||
range_str = self.headers.get('Content-Range')
|
||||
|
||||
range_str = self.headers.get('Range')
|
||||
if range_str is not None:
|
||||
range_ = webob.byterange.ContentRange.parse(range_str)
|
||||
# NOTE(dharinic): Ensure that a range like 1-4/* for an image
|
||||
# size of 3 is invalidated.
|
||||
if range_ is None or (range_.length is None and
|
||||
range_.stop > image_size):
|
||||
msg = _('Malformed Content-Range header: %s') % range_str
|
||||
raise webob.exc.HTTPRequestRangeNotSatisfiable(explanation=msg)
|
||||
|
||||
# NOTE(dharinic): We do not support multi range requests.
|
||||
if ',' in range_str:
|
||||
msg = ("Requests with multiple ranges are not supported in "
|
||||
"Glance. You may make multiple single-range requests "
|
||||
"instead.")
|
||||
raise webob.exc.HTTPBadRequest(explanation=msg)
|
||||
|
||||
range_ = webob.byterange.Range.parse(range_str)
|
||||
if range_ is None:
|
||||
msg = ("Invalid Range header.")
|
||||
raise webob.exc.HTTPRequestRangeNotSatisfiable(msg)
|
||||
# NOTE(dharinic): Ensure that a range like bytes=4- for an image
|
||||
# size of 3 is invalidated as per rfc7233.
|
||||
if range_.start >= image_size:
|
||||
msg = ("Invalid start position in Range header. "
|
||||
"Start position MUST be in the inclusive range [0, %s]."
|
||||
% (image_size - 1))
|
||||
raise webob.exc.HTTPRequestRangeNotSatisfiable(msg)
|
||||
return range_
|
||||
|
||||
# NOTE(dharinic): For backward compatibility reasons, we maintain
|
||||
# support for 'Content-Range' in requests even though it's not
|
||||
# correct to use it in requests..
|
||||
c_range_str = self.headers.get('Content-Range')
|
||||
if c_range_str is not None:
|
||||
content_range = webob.byterange.ContentRange.parse(c_range_str)
|
||||
# NOTE(dharinic): Ensure that a content range like 1-4/* for an
|
||||
# image size of 3 is invalidated.
|
||||
if content_range is None:
|
||||
msg = ("Invalid Content-Range header.")
|
||||
raise webob.exc.HTTPRequestRangeNotSatisfiable(msg)
|
||||
if (content_range.length is None and
|
||||
content_range.stop > image_size):
|
||||
msg = ("Invalid stop position in Content-Range header. "
|
||||
"The stop position MUST be in the inclusive range "
|
||||
"[0, %s]." % (image_size - 1))
|
||||
raise webob.exc.HTTPRequestRangeNotSatisfiable(msg)
|
||||
if content_range.start >= image_size:
|
||||
msg = ("Invalid start position in Content-Range header. "
|
||||
"Start position MUST be in the inclusive range [0, %s]."
|
||||
% (image_size - 1))
|
||||
raise webob.exc.HTTPRequestRangeNotSatisfiable(msg)
|
||||
return content_range
|
||||
|
||||
|
||||
class JSONRequestDeserializer(object):
|
||||
valid_transfer_encoding = frozenset(['chunked', 'compress', 'deflate',
|
||||
|
@ -743,7 +743,54 @@ class TestImages(functional.FunctionalTest):
|
||||
|
||||
self.stop_servers()
|
||||
|
||||
def test_download_random_access(self):
|
||||
def test_download_random_access_w_range_request(self):
|
||||
"""
|
||||
Test partial download 'Range' requests for images (random image access)
|
||||
"""
|
||||
self.start_servers(**self.__dict__.copy())
|
||||
# Create an image (with two deployer-defined properties)
|
||||
path = self._url('/v2/images')
|
||||
headers = self._headers({'content-type': 'application/json'})
|
||||
data = jsonutils.dumps({'name': 'image-2', 'type': 'kernel',
|
||||
'bar': 'foo', 'disk_format': 'aki',
|
||||
'container_format': 'aki', 'xyz': 'abc'})
|
||||
response = requests.post(path, headers=headers, data=data)
|
||||
self.assertEqual(http.CREATED, response.status_code)
|
||||
image = jsonutils.loads(response.text)
|
||||
image_id = image['id']
|
||||
|
||||
# Upload data to image
|
||||
image_data = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
|
||||
path = self._url('/v2/images/%s/file' % image_id)
|
||||
headers = self._headers({'Content-Type': 'application/octet-stream'})
|
||||
response = requests.put(path, headers=headers, data=image_data)
|
||||
self.assertEqual(http.NO_CONTENT, response.status_code)
|
||||
|
||||
# test for success on satisfiable Range request.
|
||||
range_ = 'bytes=3-10'
|
||||
headers = self._headers({'Range': range_})
|
||||
path = self._url('/v2/images/%s/file' % image_id)
|
||||
response = requests.get(path, headers=headers)
|
||||
self.assertEqual(http.PARTIAL_CONTENT, response.status_code)
|
||||
self.assertEqual('DEFGHIJK', response.text)
|
||||
|
||||
# test for failure on unsatisfiable Range request.
|
||||
range_ = 'bytes=10-5'
|
||||
headers = self._headers({'Range': range_})
|
||||
path = self._url('/v2/images/%s/file' % image_id)
|
||||
response = requests.get(path, headers=headers)
|
||||
self.assertEqual(http.REQUESTED_RANGE_NOT_SATISFIABLE,
|
||||
response.status_code)
|
||||
|
||||
self.stop_servers()
|
||||
|
||||
def test_download_random_access_w_content_range(self):
|
||||
"""
|
||||
Even though Content-Range is incorrect on requests, we support it
|
||||
for backward compatibility with clients written for pre-Pike Glance.
|
||||
The following test is for 'Content-Range' requests, which we have
|
||||
to ensure that we prevent regression.
|
||||
"""
|
||||
self.start_servers(**self.__dict__.copy())
|
||||
# Create another image (with two deployer-defined properties)
|
||||
path = self._url('/v2/images')
|
||||
@ -772,17 +819,18 @@ class TestImages(functional.FunctionalTest):
|
||||
headers = self._headers({'Content-Range': content_range})
|
||||
path = self._url('/v2/images/%s/file' % image_id)
|
||||
response = requests.get(path, headers=headers)
|
||||
self.assertEqual(206, response.status_code)
|
||||
self.assertEqual(http.PARTIAL_CONTENT, response.status_code)
|
||||
result_body += response.text
|
||||
|
||||
self.assertEqual(result_body, image_data)
|
||||
|
||||
# test for failure on unsatisfiable request range.
|
||||
# test for failure on unsatisfiable request for ContentRange.
|
||||
content_range = 'bytes 3-16/15'
|
||||
headers = self._headers({'Content-Range': content_range})
|
||||
path = self._url('/v2/images/%s/file' % image_id)
|
||||
response = requests.get(path, headers=headers)
|
||||
self.assertEqual(416, response.status_code)
|
||||
self.assertEqual(http.REQUESTED_RANGE_NOT_SATISFIABLE,
|
||||
response.status_code)
|
||||
|
||||
self.stop_servers()
|
||||
|
||||
|
@ -67,7 +67,7 @@ class RequestTest(test_utils.BaseTestCase):
|
||||
def test_content_range(self):
|
||||
request = wsgi.Request.blank('/tests/123')
|
||||
request.headers["Content-Range"] = 'bytes 10-99/*'
|
||||
range_ = request.get_content_range(120)
|
||||
range_ = request.get_range_from_request(120)
|
||||
self.assertEqual(10, range_.start)
|
||||
self.assertEqual(100, range_.stop) # non-inclusive
|
||||
self.assertIsNone(range_.length)
|
||||
@ -76,7 +76,20 @@ class RequestTest(test_utils.BaseTestCase):
|
||||
request = wsgi.Request.blank('/tests/123')
|
||||
request.headers["Content-Range"] = 'bytes=0-99'
|
||||
self.assertRaises(webob.exc.HTTPRequestRangeNotSatisfiable,
|
||||
request.get_content_range, 120)
|
||||
request.get_range_from_request, 120)
|
||||
|
||||
def test_range(self):
|
||||
request = wsgi.Request.blank('/tests/123')
|
||||
request.headers["Range"] = 'bytes=10-99'
|
||||
range_ = request.get_range_from_request(120)
|
||||
self.assertEqual(10, range_.start)
|
||||
self.assertEqual(100, range_.end) # non-inclusive
|
||||
|
||||
def test_range_invalid(self):
|
||||
request = wsgi.Request.blank('/tests/123')
|
||||
request.headers["Range"] = 'bytes=150-'
|
||||
self.assertRaises(webob.exc.HTTPRequestRangeNotSatisfiable,
|
||||
request.get_range_from_request, 120)
|
||||
|
||||
def test_content_type_missing(self):
|
||||
request = wsgi.Request.blank('/tests/123')
|
||||
|
@ -63,8 +63,10 @@ class FakeImage(object):
|
||||
else:
|
||||
self._status = value
|
||||
|
||||
def get_data(self, *args, **kwargs):
|
||||
return self.data
|
||||
def get_data(self, offset=0, chunk_size=None):
|
||||
if chunk_size:
|
||||
return self.data[offset:offset + chunk_size]
|
||||
return self.data[offset:]
|
||||
|
||||
def set_data(self, data, size=None):
|
||||
self.data = ''.join(data)
|
||||
@ -548,40 +550,144 @@ class TestImageDataSerializer(test_utils.BaseTestCase):
|
||||
self.assertEqual('application/octet-stream',
|
||||
response.headers['Content-Type'])
|
||||
|
||||
def _test_partial_download_successful(self, d_range):
|
||||
def test_range_requests_for_image_downloads(self):
|
||||
"""
|
||||
Test partial download 'Range' requests for images (random image access)
|
||||
"""
|
||||
def download_successful_Range(d_range):
|
||||
request = wsgi.Request.blank('/')
|
||||
request.environ = {}
|
||||
request.headers['Range'] = d_range
|
||||
response = webob.Response()
|
||||
response.request = request
|
||||
image = FakeImage(size=3, data=[b'X', b'Y', b'Z'])
|
||||
self.serializer.download(response, image)
|
||||
self.assertEqual(206, response.status_code)
|
||||
self.assertEqual('2', response.headers['Content-Length'])
|
||||
self.assertEqual('bytes 1-2/3', response.headers['Content-Range'])
|
||||
self.assertEqual(b'YZ', response.body)
|
||||
|
||||
download_successful_Range('bytes=1-2')
|
||||
download_successful_Range('bytes=1-')
|
||||
download_successful_Range('bytes=1-3')
|
||||
download_successful_Range('bytes=-2')
|
||||
download_successful_Range('bytes=1-100')
|
||||
|
||||
def full_image_download_w_range(d_range):
|
||||
request = wsgi.Request.blank('/')
|
||||
request.environ = {}
|
||||
request.headers['Range'] = d_range
|
||||
response = webob.Response()
|
||||
response.request = request
|
||||
image = FakeImage(size=3, data=[b'X', b'Y', b'Z'])
|
||||
self.serializer.download(response, image)
|
||||
self.assertEqual(206, response.status_code)
|
||||
self.assertEqual('3', response.headers['Content-Length'])
|
||||
self.assertEqual('bytes 0-2/3', response.headers['Content-Range'])
|
||||
self.assertEqual(b'XYZ', response.body)
|
||||
|
||||
full_image_download_w_range('bytes=0-')
|
||||
full_image_download_w_range('bytes=0-2')
|
||||
full_image_download_w_range('bytes=0-3')
|
||||
full_image_download_w_range('bytes=-3')
|
||||
full_image_download_w_range('bytes=-4')
|
||||
full_image_download_w_range('bytes=0-100')
|
||||
full_image_download_w_range('bytes=-100')
|
||||
|
||||
def download_failures_Range(d_range):
|
||||
request = wsgi.Request.blank('/')
|
||||
request.environ = {}
|
||||
request.headers['Range'] = d_range
|
||||
response = webob.Response()
|
||||
response.request = request
|
||||
image = FakeImage(size=3, data=[b'Z', b'Z', b'Z'])
|
||||
self.assertRaises(webob.exc.HTTPRequestRangeNotSatisfiable,
|
||||
self.serializer.download,
|
||||
response, image)
|
||||
return
|
||||
|
||||
download_failures_Range('bytes=4-1')
|
||||
download_failures_Range('bytes=4-')
|
||||
download_failures_Range('bytes=3-')
|
||||
download_failures_Range('bytes=1')
|
||||
download_failures_Range('bytes=100')
|
||||
download_failures_Range('bytes=100-')
|
||||
download_failures_Range('bytes=')
|
||||
|
||||
def test_multi_range_requests_raises_bad_request_error(self):
|
||||
request = wsgi.Request.blank('/')
|
||||
request.environ = {}
|
||||
request.headers['Content-Range'] = d_range
|
||||
request.headers['Range'] = 'bytes=0-0,-1'
|
||||
response = webob.Response()
|
||||
response.request = request
|
||||
image = FakeImage(size=3, data=[b'Z', b'Z', b'Z'])
|
||||
self.serializer.download(response, image)
|
||||
self.assertEqual(206, response.status_code)
|
||||
self.assertEqual('2', response.headers['Content-Length'])
|
||||
|
||||
def test_partial_download_successful_with_range(self):
|
||||
self._test_partial_download_successful('bytes 1-2/3')
|
||||
self._test_partial_download_successful('bytes 1-2/*')
|
||||
|
||||
def _test_partial_download_failures(self, d_range):
|
||||
request = wsgi.Request.blank('/')
|
||||
request.environ = {}
|
||||
request.headers['Content-Range'] = d_range
|
||||
response = webob.Response()
|
||||
response.request = request
|
||||
image = FakeImage(size=3, data=[b'Z', b'Z', b'Z'])
|
||||
self.assertRaises(webob.exc.HTTPRequestRangeNotSatisfiable,
|
||||
self.assertRaises(webob.exc.HTTPBadRequest,
|
||||
self.serializer.download,
|
||||
response, image)
|
||||
return
|
||||
|
||||
def test_partial_download_failure_with_range(self):
|
||||
self._test_partial_download_failures('bytes 1-4/3')
|
||||
self._test_partial_download_failures('bytes 1-4/*')
|
||||
self._test_partial_download_failures('bytes 4-1/3')
|
||||
self._test_partial_download_failures('bytes 4-1/*')
|
||||
|
||||
def test_download_failure_with_valid_range(self):
|
||||
with mock.patch.object(glance.api.policy.ImageProxy,
|
||||
'get_data') as mock_get_data:
|
||||
mock_get_data.side_effect = glance_store.NotFound(image="image")
|
||||
request = wsgi.Request.blank('/')
|
||||
request.environ = {}
|
||||
request.headers['Range'] = 'bytes=1-2'
|
||||
response = webob.Response()
|
||||
response.request = request
|
||||
image = FakeImage(size=3, data=[b'Z', b'Z', b'Z'])
|
||||
image.get_data = mock_get_data
|
||||
self.assertRaises(webob.exc.HTTPNoContent,
|
||||
self.serializer.download,
|
||||
response, image)
|
||||
|
||||
def test_content_range_requests_for_image_downloads(self):
|
||||
"""
|
||||
Even though Content-Range is incorrect on requests, we support it
|
||||
for backward compatibility with clients written for pre-Pike
|
||||
Glance.
|
||||
The following test is for 'Content-Range' requests, which we have
|
||||
to ensure that we prevent regression.
|
||||
"""
|
||||
def download_successful_ContentRange(d_range):
|
||||
request = wsgi.Request.blank('/')
|
||||
request.environ = {}
|
||||
request.headers['Content-Range'] = d_range
|
||||
response = webob.Response()
|
||||
response.request = request
|
||||
image = FakeImage(size=3, data=[b'X', b'Y', b'Z'])
|
||||
self.serializer.download(response, image)
|
||||
self.assertEqual(206, response.status_code)
|
||||
self.assertEqual('2', response.headers['Content-Length'])
|
||||
self.assertEqual('bytes 1-2/3', response.headers['Content-Range'])
|
||||
self.assertEqual(b'YZ', response.body)
|
||||
|
||||
download_successful_ContentRange('bytes 1-2/3')
|
||||
download_successful_ContentRange('bytes 1-2/*')
|
||||
|
||||
def download_failures_ContentRange(d_range):
|
||||
request = wsgi.Request.blank('/')
|
||||
request.environ = {}
|
||||
request.headers['Content-Range'] = d_range
|
||||
response = webob.Response()
|
||||
response.request = request
|
||||
image = FakeImage(size=3, data=[b'Z', b'Z', b'Z'])
|
||||
self.assertRaises(webob.exc.HTTPRequestRangeNotSatisfiable,
|
||||
self.serializer.download,
|
||||
response, image)
|
||||
return
|
||||
|
||||
download_failures_ContentRange('bytes -3/3')
|
||||
download_failures_ContentRange('bytes 1-/3')
|
||||
download_failures_ContentRange('bytes 1-3/3')
|
||||
download_failures_ContentRange('bytes 1-4/3')
|
||||
download_failures_ContentRange('bytes 1-4/*')
|
||||
download_failures_ContentRange('bytes 4-1/3')
|
||||
download_failures_ContentRange('bytes 4-1/*')
|
||||
download_failures_ContentRange('bytes 4-8/*')
|
||||
download_failures_ContentRange('bytes 4-8/10')
|
||||
download_failures_ContentRange('bytes 4-8/3')
|
||||
|
||||
def test_download_failure_with_valid_content_range(self):
|
||||
with mock.patch.object(glance.api.policy.ImageProxy,
|
||||
'get_data') as mock_get_data:
|
||||
mock_get_data.side_effect = glance_store.NotFound(image="image")
|
||||
|
@ -0,0 +1,13 @@
|
||||
---
|
||||
fixes:
|
||||
- |
|
||||
Glance had been accepting the Content-Range header for GET v2/images/{image_id}/file requests,
|
||||
contrary to RFC 7233.
|
||||
Following RFC 7233, Glance will now:
|
||||
|
||||
* Accept the Range header in requests to serve partial images.
|
||||
* Include a ``Content-Range`` header upon successful delivery of the requested partial content.
|
||||
|
||||
Please note that not all Glance storage backends support partial downloads. A Range request to a
|
||||
Glance server with such a backend will result in the entire image content being delivered
|
||||
despite the 206 response code.
|
Loading…
x
Reference in New Issue
Block a user