Handling HTTP range requests in Glance
Currently Glance does not send Partial response codes while handling HTTP range requests. Also, content length is not appropriately set. This patch is to send partial response code and to set the correct content length based on the range request for image download. Upon success status code 206 is sent and the content length is set to the requested range. Upon failure, there can be 2 cases: * If the HTTP range request for the image download is bad (For example, requesting download of range of bytes 10 to 50 bytes when there are only 48 bytes), status code is set to 416 and HTTPRequestRangeNotSatisfiable is raised. * If the content range is valid, but the request is not satisfiable due to glance_store side erros or privacy issues, appropriate exceptions are raised. APIImpact DocImpact Closes-Bug: #1417069 Closes-Bug: #1624508 Closes-Bug: #1399851 Closes-Bug: #1618928 Change-Id: I3cd47b998be79604511b3cd4879209820cf776b7
This commit is contained in:
parent
34b34a806a
commit
400230cd9d
@ -17,6 +17,7 @@ from oslo_config import cfg
|
||||
from oslo_log import log as logging
|
||||
from oslo_utils import encodeutils
|
||||
from oslo_utils import excutils
|
||||
import six
|
||||
import webob.exc
|
||||
|
||||
import glance.api.policy
|
||||
@ -281,17 +282,20 @@ class ResponseSerializer(wsgi.JSONResponseSerializer):
|
||||
|
||||
def download(self, response, image):
|
||||
offset, chunk_size = 0, None
|
||||
range_val = response.request.get_content_range()
|
||||
# NOTE(dharinic): In case of a malformed content range,
|
||||
# glance/common/wsgi.py will raise HTTPRequestRangeNotSatisfiable
|
||||
# (setting status_code to 416)
|
||||
range_val = response.request.get_content_range(image.size)
|
||||
|
||||
if range_val:
|
||||
# NOTE(flaper87): if not present, both, start
|
||||
# and stop, will be None.
|
||||
if range_val.start is not None:
|
||||
if range_val.start is not None and range_val.stop is not None:
|
||||
offset = range_val.start
|
||||
|
||||
if range_val.stop is not None:
|
||||
chunk_size = range_val.stop - offset
|
||||
|
||||
response.status_int = 206
|
||||
|
||||
response.headers['Content-Type'] = 'application/octet-stream'
|
||||
|
||||
try:
|
||||
@ -300,6 +304,11 @@ class ResponseSerializer(wsgi.JSONResponseSerializer):
|
||||
# an iterator very strange
|
||||
response.app_iter = iter(image.get_data(offset=offset,
|
||||
chunk_size=chunk_size))
|
||||
# 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:
|
||||
chunk_size = image.size
|
||||
except glance_store.NotFound as e:
|
||||
raise webob.exc.HTTPNoContent(explanation=e.msg)
|
||||
except glance_store.RemoteServiceUnavailable as e:
|
||||
@ -317,7 +326,7 @@ class ResponseSerializer(wsgi.JSONResponseSerializer):
|
||||
response.headers['Content-MD5'] = image.checksum
|
||||
# NOTE(markwash): "response.app_iter = ..." also erroneously resets the
|
||||
# content-length
|
||||
response.headers['Content-Length'] = str(image.size)
|
||||
response.headers['Content-Length'] = six.text_type(chunk_size)
|
||||
|
||||
def upload(self, response, result):
|
||||
response.status_int = 204
|
||||
|
@ -771,14 +771,17 @@ class Request(webob.Request):
|
||||
langs = i18n.get_available_languages('glance')
|
||||
return self.accept_language.best_match(langs)
|
||||
|
||||
def get_content_range(self):
|
||||
def get_content_range(self, image_size):
|
||||
"""Return the `Range` in a request."""
|
||||
range_str = self.headers.get('Content-Range')
|
||||
if range_str is not None:
|
||||
range_ = webob.byterange.ContentRange.parse(range_str)
|
||||
if range_ is None:
|
||||
# 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.HTTPBadRequest(explanation=msg)
|
||||
raise webob.exc.HTTPRequestRangeNotSatisfiable(explanation=msg)
|
||||
return range_
|
||||
|
||||
|
||||
|
@ -768,10 +768,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)
|
||||
result_body += response.text
|
||||
|
||||
self.assertEqual(result_body, image_data)
|
||||
|
||||
# test for failure on unsatisfiable request range.
|
||||
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.stop_servers()
|
||||
|
||||
def test_download_policy_when_cache_is_not_enabled(self):
|
||||
|
@ -66,7 +66,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()
|
||||
range_ = request.get_content_range(120)
|
||||
self.assertEqual(10, range_.start)
|
||||
self.assertEqual(100, range_.stop) # non-inclusive
|
||||
self.assertIsNone(range_.length)
|
||||
@ -74,8 +74,8 @@ class RequestTest(test_utils.BaseTestCase):
|
||||
def test_content_range_invalid(self):
|
||||
request = wsgi.Request.blank('/tests/123')
|
||||
request.headers["Content-Range"] = 'bytes=0-99'
|
||||
self.assertRaises(webob.exc.HTTPBadRequest,
|
||||
request.get_content_range)
|
||||
self.assertRaises(webob.exc.HTTPRequestRangeNotSatisfiable,
|
||||
request.get_content_range, 120)
|
||||
|
||||
def test_content_type_missing(self):
|
||||
request = wsgi.Request.blank('/tests/123')
|
||||
|
@ -546,6 +546,54 @@ class TestImageDataSerializer(test_utils.BaseTestCase):
|
||||
self.assertEqual('application/octet-stream',
|
||||
response.headers['Content-Type'])
|
||||
|
||||
def _test_partial_download_successful(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.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.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['Content-Range'] = 'bytes %s-%s/3' % (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_download_with_checksum(self):
|
||||
request = wsgi.Request.blank('/')
|
||||
request.environ = {}
|
||||
|
Loading…
Reference in New Issue
Block a user