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:
Dharini Chandrasekar 2016-09-08 17:03:40 +00:00
parent 34b34a806a
commit 400230cd9d
5 changed files with 79 additions and 11 deletions

View File

@ -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

View File

@ -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_

View File

@ -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):

View File

@ -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')

View File

@ -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 = {}