Merge "Handling HTTP range requests in Glance"
This commit is contained in:
commit
6d7b15c4e4
@ -18,6 +18,7 @@ from oslo_config import cfg
|
|||||||
from oslo_log import log as logging
|
from oslo_log import log as logging
|
||||||
from oslo_utils import encodeutils
|
from oslo_utils import encodeutils
|
||||||
from oslo_utils import excutils
|
from oslo_utils import excutils
|
||||||
|
import six
|
||||||
import webob.exc
|
import webob.exc
|
||||||
|
|
||||||
import glance.api.policy
|
import glance.api.policy
|
||||||
@ -282,17 +283,20 @@ class ResponseSerializer(wsgi.JSONResponseSerializer):
|
|||||||
|
|
||||||
def download(self, response, image):
|
def download(self, response, image):
|
||||||
offset, chunk_size = 0, None
|
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:
|
if range_val:
|
||||||
# NOTE(flaper87): if not present, both, start
|
# NOTE(flaper87): if not present, both, start
|
||||||
# and stop, will be None.
|
# 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
|
offset = range_val.start
|
||||||
|
|
||||||
if range_val.stop is not None:
|
|
||||||
chunk_size = range_val.stop - offset
|
chunk_size = range_val.stop - offset
|
||||||
|
|
||||||
|
response.status_int = 206
|
||||||
|
|
||||||
response.headers['Content-Type'] = 'application/octet-stream'
|
response.headers['Content-Type'] = 'application/octet-stream'
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@ -301,6 +305,11 @@ class ResponseSerializer(wsgi.JSONResponseSerializer):
|
|||||||
# an iterator very strange
|
# an iterator very strange
|
||||||
response.app_iter = iter(image.get_data(offset=offset,
|
response.app_iter = iter(image.get_data(offset=offset,
|
||||||
chunk_size=chunk_size))
|
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:
|
except glance_store.NotFound as e:
|
||||||
raise webob.exc.HTTPNoContent(explanation=e.msg)
|
raise webob.exc.HTTPNoContent(explanation=e.msg)
|
||||||
except glance_store.RemoteServiceUnavailable as e:
|
except glance_store.RemoteServiceUnavailable as e:
|
||||||
@ -318,7 +327,7 @@ class ResponseSerializer(wsgi.JSONResponseSerializer):
|
|||||||
response.headers['Content-MD5'] = image.checksum
|
response.headers['Content-MD5'] = image.checksum
|
||||||
# NOTE(markwash): "response.app_iter = ..." also erroneously resets the
|
# NOTE(markwash): "response.app_iter = ..." also erroneously resets the
|
||||||
# content-length
|
# content-length
|
||||||
response.headers['Content-Length'] = str(image.size)
|
response.headers['Content-Length'] = six.text_type(chunk_size)
|
||||||
|
|
||||||
def upload(self, response, result):
|
def upload(self, response, result):
|
||||||
response.status_int = 204
|
response.status_int = 204
|
||||||
|
@ -972,14 +972,17 @@ class Request(webob.Request):
|
|||||||
langs = i18n.get_available_languages('glance')
|
langs = i18n.get_available_languages('glance')
|
||||||
return self.accept_language.best_match(langs)
|
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."""
|
"""Return the `Range` in a request."""
|
||||||
range_str = self.headers.get('Content-Range')
|
range_str = self.headers.get('Content-Range')
|
||||||
if range_str is not None:
|
if range_str is not None:
|
||||||
range_ = webob.byterange.ContentRange.parse(range_str)
|
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
|
msg = _('Malformed Content-Range header: %s') % range_str
|
||||||
raise webob.exc.HTTPBadRequest(explanation=msg)
|
raise webob.exc.HTTPRequestRangeNotSatisfiable(explanation=msg)
|
||||||
return range_
|
return range_
|
||||||
|
|
||||||
|
|
||||||
|
@ -771,10 +771,18 @@ class TestImages(functional.FunctionalTest):
|
|||||||
headers = self._headers({'Content-Range': content_range})
|
headers = self._headers({'Content-Range': content_range})
|
||||||
path = self._url('/v2/images/%s/file' % image_id)
|
path = self._url('/v2/images/%s/file' % image_id)
|
||||||
response = requests.get(path, headers=headers)
|
response = requests.get(path, headers=headers)
|
||||||
|
self.assertEqual(206, response.status_code)
|
||||||
result_body += response.text
|
result_body += response.text
|
||||||
|
|
||||||
self.assertEqual(result_body, image_data)
|
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()
|
self.stop_servers()
|
||||||
|
|
||||||
def test_download_policy_when_cache_is_not_enabled(self):
|
def test_download_policy_when_cache_is_not_enabled(self):
|
||||||
|
@ -67,7 +67,7 @@ class RequestTest(test_utils.BaseTestCase):
|
|||||||
def test_content_range(self):
|
def test_content_range(self):
|
||||||
request = wsgi.Request.blank('/tests/123')
|
request = wsgi.Request.blank('/tests/123')
|
||||||
request.headers["Content-Range"] = 'bytes 10-99/*'
|
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(10, range_.start)
|
||||||
self.assertEqual(100, range_.stop) # non-inclusive
|
self.assertEqual(100, range_.stop) # non-inclusive
|
||||||
self.assertIsNone(range_.length)
|
self.assertIsNone(range_.length)
|
||||||
@ -75,8 +75,8 @@ class RequestTest(test_utils.BaseTestCase):
|
|||||||
def test_content_range_invalid(self):
|
def test_content_range_invalid(self):
|
||||||
request = wsgi.Request.blank('/tests/123')
|
request = wsgi.Request.blank('/tests/123')
|
||||||
request.headers["Content-Range"] = 'bytes=0-99'
|
request.headers["Content-Range"] = 'bytes=0-99'
|
||||||
self.assertRaises(webob.exc.HTTPBadRequest,
|
self.assertRaises(webob.exc.HTTPRequestRangeNotSatisfiable,
|
||||||
request.get_content_range)
|
request.get_content_range, 120)
|
||||||
|
|
||||||
def test_content_type_missing(self):
|
def test_content_type_missing(self):
|
||||||
request = wsgi.Request.blank('/tests/123')
|
request = wsgi.Request.blank('/tests/123')
|
||||||
|
@ -548,6 +548,54 @@ class TestImageDataSerializer(test_utils.BaseTestCase):
|
|||||||
self.assertEqual('application/octet-stream',
|
self.assertEqual('application/octet-stream',
|
||||||
response.headers['Content-Type'])
|
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):
|
def test_download_with_checksum(self):
|
||||||
request = wsgi.Request.blank('/')
|
request = wsgi.Request.blank('/')
|
||||||
request.environ = {}
|
request.environ = {}
|
||||||
|
Loading…
Reference in New Issue
Block a user