Fetch an on-boarded VNF package with HTTP_RANGE

The GET method fetches the content of a VNF package identified by
the VNF package identifier allocated by the NFVO.

The 'HTTP_RANGE' header is inspected for a valid value.
If it is present and valid, then the package content within
the range is fetched.If not present the entire content is
fetched

A 'size' column has been added to vnf_packages db table.
When the VNF package content is uploaded, the size of the
csar zip is persisted in this newly added 'size' column of
vnf_packages db table.

When VNF_Package content is fetched for the first time, its size is
retrieved from glance store and the size column in vnf packages table
is populated with this value. For subsequent fetch calls the size of
csar file content is retrieved from 'size' column in vnf_packages
table.

A lot of the content fetching code has been reused from
glance/api/v2/image_data.py ResponseSerializer class
download function

Blueprint: bp/enhance-vnf-package-support-part1

Change-Id: Ib49ef8b1e81ca4a4b7e3ac4a0836c111ce7da4a3
This commit is contained in:
Sameer 2019-11-18 15:47:49 +05:30 committed by Prashant Bhole
parent 4a2fd6c292
commit d3cc82139e
21 changed files with 1701 additions and 43 deletions

View File

@ -7,6 +7,20 @@ content_type:
in: header
required: true
type: string
fetch_content_type:
description: |
The fetched VNF Package will be returned with Content-Type set
as `application/zip` in the response header.
in: header
required: true
type: string
range:
description: |
The byte range of VNF Package to be downloaded is specified in the Range
header.
in: header
required: false
type: string
# variables in path
alias_path:

View File

@ -15,6 +15,10 @@
204:
default: |
The server has fulfilled the request by deleting the resource.
206:
default: |
Partial Content, The server has fulfilled the partial GET request for the
resource.
300:
default: |
There are multiple choices for resources. The request has to be more
@ -56,6 +60,13 @@
This operation conflicted with another operation on this resource.
duplcate_zone: |
There is already a zone with this name.
416:
default: |
Requested Range Not Satisfiable, A server SHOULD return a response with
this status code if a request included a Range request-header field,
and none of the range-specifier values in this field overlap the current
extent of the selected resource, and the request did not include an
If-Range request-header field.
500:
default: |
Something went wrong inside the service. This should not happen usually.

View File

@ -361,3 +361,50 @@ Response
.. rest_parameters:: parameters.yaml
- Content-Type: content_type
Fetch an on-boarded VNF package with HTTP_RANGE
================================================
.. rest_method:: GET /vnfpkgm/v1/vnf_packages/{vnf_package_id}/package_content
Fetch an on-boarded VNF package with HTTP_RANGE.
The GET method fetches the content of a VNF package identified by
the VNF package identifier allocated by the NFVO.
The 'HTTP_RANGE' header is inspected for a valid value.
If it is present and valid, then the package content within
the range is fetched. If not present the entire content is
fetched.
Response Codes
--------------
.. rest_status_code:: success status.yaml
- 200
- 206
.. rest_status_code:: error status.yaml
- 403
- 404
- 409
- 416
- 500
Request Parameters
------------------
.. rest_parameters:: parameters.yaml
- vnf_package_id: vnf_package_id_path
- Accept: fetch_content_type
- Range: range
Response
--------
.. rest_parameters:: parameters.yaml
- Content-Type: fetch_content_type

View File

@ -171,6 +171,123 @@ class VnfPkgmController(wsgi.Controller):
# Delete vnf_package
self.rpc_api.delete_vnf_package(context, vnf_package)
@wsgi.response(http_client.ACCEPTED)
@wsgi.expected_errors((http_client.FORBIDDEN, http_client.NOT_FOUND,
http_client.CONFLICT,
http_client.REQUESTED_RANGE_NOT_SATISFIABLE))
def fetch_vnf_package_content(self, request, id):
context = request.environ['tacker.context']
context.can(vnf_package_policies.VNFPKGM % 'fetch_package_content')
vnf_package = self._get_vnf_package(id, request)
if vnf_package.onboarding_state != \
fields.PackageOnboardingStateType.ONBOARDED:
msg = _("VNF Package %(id)s onboarding state "
"is not %(onboarding)s")
raise webob.exc.HTTPConflict(explanation=msg % {"id": id,
"onboarding": fields.PackageOnboardingStateType.ONBOARDED})
if vnf_package.size == 0:
try:
zip_file_size = glance_store.get_csar_size(id,
vnf_package.location_glance_store)
vnf_package.size = zip_file_size
vnf_package.save()
except exceptions.VnfPackageLocationInvalid:
msg = _("Vnf package not present at location")
raise webob.exc.HTTPNotFound(explanation=msg)
else:
zip_file_size = vnf_package.size
range_val = self._get_range_from_request(request, zip_file_size)
return self._download(
request.response, range_val, id, vnf_package.location_glance_store,
zip_file_size)
def _download(self, response, range_val, uuid, location, zip_file_size):
offset, chunk_size = 0, None
if range_val:
if isinstance(range_val, webob.byterange.Range):
response_end = zip_file_size - 1
# NOTE(sameert): 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(sameert): 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) < zip_file_size:
offset = zip_file_size + range_val.start
if range_val.end is not None and range_val.end < zip_file_size:
chunk_size = range_val.end - offset
response_end = range_val.end - 1
else:
chunk_size = zip_file_size - offset
response.status_int = 206
response.headers['Content-Type'] = 'application/zip'
response.app_iter = self._get_csar_zip_data(uuid,
location, offset, chunk_size)
# NOTE(sameert): In case of a full zip download, when
# chunk_size was none, reset it to zip.size to set the
# response header's Content-Length.
if chunk_size is not None:
response.headers['Content-Range'] = 'bytes %s-%s/%s'\
% (offset,
response_end,
zip_file_size)
else:
chunk_size = zip_file_size
response.headers['Content-Length'] = six.text_type(chunk_size)
return response
def _get_csar_zip_data(self, uuid, location, offset=0, chunk_size=None):
try:
resp, size = glance_store.load_csar_iter(
uuid, location, offset=offset, chunk_size=chunk_size)
except exceptions.VnfPackageLocationInvalid:
msg = _("Vnf package not present at location")
raise webob.exc.HTTPServerError(explanation=msg)
return resp
def _get_range_from_request(self, request, zip_file_size):
range_str = request._headers.environ.get('HTTP_RANGE')
if range_str is not None:
# NOTE(sameert): We do not support multi range requests.
if ',' in range_str:
msg = _("Requests with multiple ranges are not supported in "
"Tacker. 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:
range_err_msg = _("The byte range passed in the 'Range' header"
"did not match any available byte range in the VNF package"
"file")
raise webob.exc.HTTPRequestRangeNotSatisfiable(
explanation=range_err_msg)
# NOTE(sameert): Ensure that a range like bytes=4- for an zip
# size of 3 is invalidated as per rfc7233.
if range_.start >= zip_file_size:
msg = _("Invalid start position in Range header. "
"Start position MUST be in the inclusive range"
"[0, %s].") % (zip_file_size - 1)
raise webob.exc.HTTPRequestRangeNotSatisfiable(
explanation=msg)
return range_
@wsgi.response(http_client.ACCEPTED)
@wsgi.expected_errors((http_client.FORBIDDEN, http_client.NOT_FOUND,
http_client.CONFLICT))
@ -207,7 +324,7 @@ class VnfPkgmController(wsgi.Controller):
vnf_package.algorithm = CONF.vnf_package.hashing_algorithm
vnf_package.hash = multihash
vnf_package.location_glance_store = location
vnf_package.size = size
vnf_package.save()
# process vnf_package

View File

@ -64,7 +64,8 @@ class VnfpkgmAPIRouter(wsgi.Router):
methods, controller, default_resource)
# Allowed methods on /vnf_packages/{id}/package_content resource
methods = {"PUT": "upload_vnf_package_content"}
methods = {"PUT": "upload_vnf_package_content",
"GET": "fetch_vnf_package_content"}
self._setup_route(mapper, "/vnf_packages/{id}/package_content",
methods, controller, default_resource)

View File

@ -286,6 +286,10 @@ class VNFPackageURLInvalid(Invalid):
message = _("Failed to open URL %(url)s")
class VnfPackageLocationInvalid(Invalid):
message = _("Failed to find location: %(location)")
class InvalidZipFile(Invalid):
message = _("Invalid zip file : %(path)s")
@ -299,6 +303,10 @@ class FailedToGetVnfdData(Invalid):
message = _("Failed to get csar zip file from glance store: %(error)s")
class FailedToGetVnfPackageDetails(Invalid):
message = _("Failed to get vnf package details: %(error)s")
class InvalidCSAR(Invalid):
message = _("Invalid csar: %(error)s")

View File

@ -265,7 +265,6 @@ class Conductor(manager.Manager):
def upload_vnf_package_from_uri(self, context, vnf_package,
address_information, user_name=None,
password=None):
body = {"address_information": address_information}
(location, size, checksum, multihash,
loc_meta) = glance_store.store_csar(context, vnf_package.id, body)
@ -276,7 +275,7 @@ class Conductor(manager.Manager):
vnf_package.algorithm = CONF.vnf_package.hashing_algorithm
vnf_package.hash = multihash
vnf_package.location_glance_store = location
vnf_package.size = size
vnf_package.save()
zip_path = glance_store.load_csar(vnf_package.id, location)

View File

@ -141,6 +141,7 @@ class VnfPackage(model_base.BASE, models.SoftDeleteMixin,
algorithm = sa.Column(sa.String(64), nullable=True)
hash = sa.Column(sa.String(128), nullable=True)
location_glance_store = sa.Column(sa.Text(), nullable=True)
size = sa.Column(sa.BigInteger, nullable=False, default=0)
_metadata = orm.relationship(
VnfPackageUserData,

View File

@ -1 +1 @@
985e28392890
d2e39e01d540

View File

@ -0,0 +1,37 @@
# Copyright 2019 OpenStack Foundation
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
#
"""add size column to vnf_packages table
Revision ID: d2e39e01d540
Revises: 985e28392890
Create Date: 2019-11-27 13:30:23.599865
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'd2e39e01d540'
down_revision = '985e28392890'
# Added 'size' column to an existing table. Server_default bit will make
# existing rows 0 for that column
def upgrade(active_plugins=None, options=None):
op.add_column('vnf_packages', sa.Column('size', sa.BigInteger,
nullable=False,
server_default='0'))

View File

@ -54,7 +54,6 @@ def get_csar_data_iter(body):
def store_csar(context, package_uuid, body):
data_iter = get_csar_data_iter(body)
try:
# store CSAR file in glance_store
(location, size, checksum, multihash,
@ -91,16 +90,21 @@ def delete_csar(context, package_uuid, location):
{"uuid": package_uuid})
def get_csar_size(package_uuid, location):
try:
return glance_store.backend.get_size_from_backend(location)
except Exception:
LOG.exception("Failed to get csar data from glance store %(location)s"
"for package %(uuid)s", {"location": location, "uuid": package_uuid})
raise exceptions.VnfPackageLocationInvalid(location=location)
def load_csar(package_uuid, location):
zip_path = os.path.join(CONF.vnf_package.vnf_package_csar_path,
package_uuid + ".zip")
try:
resp, size = glance_store.backend.get_from_backend(location)
except Exception:
LOG.info("Failed to get csar data from glance store %(location)s for "
"package %(uuid)s",
{"location": location, "uuid": package_uuid})
resp, size = _get_csar_chunks(
package_uuid, location, offset=0, chunk_size=None)
try:
temp_data = open(zip_path, 'wb')
@ -115,3 +119,21 @@ def load_csar(package_uuid, location):
'error': encodeutils.exception_to_unicode(exp)})
return zip_path
def load_csar_iter(package_uuid, location, offset=0, chunk_size=None):
resp, size = _get_csar_chunks(
package_uuid, location, offset=offset, chunk_size=chunk_size)
return resp, size
def _get_csar_chunks(package_uuid, location, offset, chunk_size):
try:
resp, size = glance_store.backend.get_from_backend(location,
offset=offset,
chunk_size=chunk_size)
return resp, size
except Exception:
LOG.exception("Failed to get csar data from glance store %(location)s"
"for package %(uuid)s", {"location": location, "uuid": package_uuid})
raise exceptions.VnfPackageLocationInvalid(location=location)

View File

@ -18,6 +18,7 @@ from oslo_serialization import jsonutils as json
from oslo_utils import excutils
from oslo_utils import timeutils
from oslo_utils import uuidutils
from oslo_utils import versionutils
from oslo_versionedobjects import base as ovoo_base
from sqlalchemy.orm import joinedload
from sqlalchemy.sql import func
@ -305,8 +306,8 @@ class VnfPackage(base.TackerObject, base.TackerPersistentObject,
COMPLEX_ATTRIBUTES.extend(
vnf_software_image.VnfSoftwareImage.COMPLEX_ATTRIBUTES)
# Version 1.0: Initial version
VERSION = '1.0'
# Version 1.1: Added 'size' to persist size of VnfPackage.
VERSION = '1.1'
fields = {
'id': fields.UUIDField(nullable=False),
@ -323,8 +324,19 @@ class VnfPackage(base.TackerObject, base.TackerPersistentObject,
'vnf_deployment_flavours': fields.ObjectField(
'VnfDeploymentFlavoursList', nullable=True),
'vnfd': fields.ObjectField('VnfPackageVnfd', nullable=True),
'size': fields.IntegerField(nullable=False, default=0),
}
def __init__(self, context=None, **kwargs):
super(VnfPackage, self).__init__(context, **kwargs)
self.obj_set_defaults()
def obj_make_compatible(self, primitive, target_version):
super(VnfPackage, self).obj_make_compatible(primitive, target_version)
target_version = versionutils.convert_version_to_tuple(target_version)
if target_version < (1, 1) and 'size' in primitive:
del primitive['size']
@staticmethod
def _from_db_object(context, vnf_package, db_vnf_package,
expected_attrs=None):

View File

@ -62,6 +62,17 @@ rules = [
'path': '/vnf_packages/{vnf_package_id}'
}
]),
policy.DocumentedRuleDefault(
name=VNFPKGM % 'fetch_package_content',
check_str=base.RULE_ADMIN_OR_OWNER,
description="fetch the contents of an on-boarded VNF Package",
operations=[
{
'method': 'GET',
'path': '/vnf_packages/{vnf_package_id}/'
'package_content'
}
]),
policy.DocumentedRuleDefault(
name=VNFPKGM % 'upload_package_content',
check_str=base.RULE_ADMIN_OR_OWNER,

View File

@ -0,0 +1 @@
#### THIS IS A DUMMY FILE TO SAVE FILE SIZE #####

View File

@ -289,30 +289,9 @@ class VnfPackageTest(base.BaseTackerTest):
expected_result = [package1]
self.assertEqual(expected_result, body)
def _create_and_onboard_vnf_package(self, file_name=None):
body = jsonutils.dumps({"userDefinedData": {"foo": "bar"}})
vnf_package = self._create_vnf_package(body)
if file_name is None:
file_name = "sample_vnf_package_csar.zip"
file_path = os.path.abspath(os.path.join(os.path.dirname(__file__),
'../../etc/samples/' + file_name))
with open(file_path, 'rb') as file_object:
resp, resp_body = self.http_client.do_request(
'{base_path}/{id}/package_content'.format(
id=vnf_package['id'],
base_path=self.base_url),
"PUT", body=file_object, content_type='application/zip')
self.assertEqual(202, resp.status_code)
self._wait_for_onboard(vnf_package['id'])
return vnf_package['id']
def test_get_vnfd_from_onboarded_vnf_package_for_content_type_zip(self):
vnf_package_id = self._create_and_onboard_vnf_package()
self.addCleanup(self._delete_vnf_package, vnf_package_id)
self.addCleanup(self._disable_operational_state, vnf_package_id)
resp, resp_body = self.http_client.do_request(
'{base_path}/{id}/vnfd'.format(id=vnf_package_id,
'{base_path}/{id}/vnfd'.format(id=self.package_id1,
base_path=self.base_url),
"GET", content_type='application/zip')
self.assertEqual(200, resp.status_code)
@ -344,9 +323,9 @@ class VnfPackageTest(base.BaseTackerTest):
def test_get_vnfd_from_onboarded_vnf_package_for_content_type_text(
self, accept_header):
# Uploading vnf package with single yaml file csar.
single_yaml_csar = "sample_vnfpkg_no_meta_single_vnfd.zip"
vnf_package_id = self._create_and_onboard_vnf_package(
single_yaml_csar)
single_yaml_csar_dir = "sample_vnfpkg_no_meta_single_vnfd"
vnf_package_id = self._create_and_upload_vnf(
single_yaml_csar_dir)
self.addCleanup(self._delete_vnf_package, vnf_package_id)
self.addCleanup(self._disable_operational_state, vnf_package_id)
resp, resp_body = self.http_client.do_request(
@ -356,3 +335,62 @@ class VnfPackageTest(base.BaseTackerTest):
self.assertEqual(200, resp.status_code)
self.assertEqual('text/plain', resp.headers['Content-Type'])
self.assertIsNotNone(resp.text)
def test_fetch_vnf_package_content_partial_download_using_range(self):
"""Test partial download using 'Range' requests for csar zip"""
# test for success on satisfiable Range request.
range_ = 'bytes=3-8'
headers = {'Range': range_}
response = self.http_client.do_request(
'{base_path}/{id}/package_content'.format(
id=self.package_id1, base_path=self.base_url),
"GET", body={}, headers=headers)
self.assertEqual(206, response[0].status_code)
self.assertEqual(
'\x04\x14\x00\x00\x00\x00', response[0].content.decode(
'utf-8', 'ignore'))
self.assertEqual('6', response[0].headers['Content-Length'])
def test_fetch_vnf_package_content_full_download(self):
"""Test full download for csar zip"""
response = self.http_client.do_request(
'{base_path}/{id}/package_content'.format(
id=self.package_id1, base_path=self.base_url),
"GET", body={}, headers={})
self.assertEqual(200, response[0].status_code)
self.assertEqual('12802866', response[0].headers['Content-Length'])
def test_fetch_vnf_package_content_combined_download(self):
"""Combine two partial downloads using 'Range' requests for csar zip"""
zip_file_path = tempfile.NamedTemporaryFile(delete=True)
zipf = zipfile.ZipFile(zip_file_path, 'w', zipfile.ZIP_STORED)
# Partial download 1
range_ = 'bytes=0-10'
headers = {'Range': range_}
response_1 = self.http_client.do_request(
'{base_path}/{id}/package_content'.format(
id=self.package_id1, base_path=self.base_url),
"GET", body={}, headers=headers)
size_1 = int(response_1[0].headers['Content-Length'])
data = response_1[0].content
file_path = self._get_csar_dir_path("data.txt")
zipf.writestr(file_path, data)
# Partial download 2
range_ = 'bytes=11-12802866'
headers = {'Range': range_}
response_2 = self.http_client.do_request(
'{base_path}/{id}/package_content'.format(
id=self.package_id1, base_path=self.base_url),
"GET", body={}, headers=headers)
data = response_2[0].content
zipf.writestr(file_path, data)
zipf.close()
size_2 = int(response_2[0].headers['Content-Length'])
total_size = size_1 + size_2
self.assertEqual(True, zipfile.is_zipfile(zip_file_path))
self.assertEqual(12802866, total_size)
zip_file_path.close()

View File

@ -0,0 +1,39 @@
# Copyright (c) 2019 NTT DATA.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import os
import testtools
from tacker.common import exceptions
from tacker import context
from tacker.glance_store import store
from tacker.tests import constants
class TestStore(testtools.TestCase):
def setUp(self):
super(TestStore, self).setUp()
self.context = context.get_admin_context()
self.base_path = os.path.dirname(os.path.abspath(__file__))
def test_get_csar_size_invalid_path(self):
self.assertRaises(
exceptions.VnfPackageLocationInvalid, store.get_csar_size,
constants.UUID, 'Invalid/path')
def test_load_csar_iter_invalid_path(self):
self.assertRaises(
exceptions.VnfPackageLocationInvalid, store.load_csar_iter,
constants.UUID, 'Invalid/path')

View File

@ -108,7 +108,7 @@ class TestConductor(SqlTestCase):
mock_load_csar_data.return_value = (mock.ANY, mock.ANY)
mock_load_csar.return_value = '/var/lib/tacker/5f5d99c6-844a' \
'-4c31-9e6d-ab21b87dcfff.zip'
mock_store.return_value = 'location', 'size', 'checksum',\
mock_store.return_value = 'location', 0, 'checksum',\
'multihash', 'loc_meta'
self.conductor.upload_vnf_package_from_uri(self.context,
self.vnf_package,

View File

@ -30,7 +30,8 @@ vnf_package_data = {'algorithm': None, 'hash': None,
'user_data': {'abc': 'xyz'},
'created_at': datetime.datetime(
2019, 8, 8, 0, 0, 0, tzinfo=iso8601.UTC),
'deleted': False
'deleted': False,
'size': 0
}
software_image = {

View File

@ -46,6 +46,7 @@ class TestVnfPackage(SqlTestCase):
self.assertEqual('CREATED', vnfpkgm.onboarding_state)
self.assertEqual('NOT_IN_USE', vnfpkgm.usage_state)
self.assertEqual('DISABLED', vnfpkgm.operational_state)
self.assertEqual(0, vnfpkgm.size)
def test_create_without_user_define_data(self):
vnf_pack = fakes.vnf_package_data
@ -177,3 +178,11 @@ class TestVnfPackage(SqlTestCase):
vnfpkgm_list = objects.VnfPackagesList.get_by_filters(
self.context, filters=filters)
self.assertEqual(1, len(vnfpkgm_list))
def test_obj_make_compatible(self):
data = {'id': self.vnf_package.id}
vnf_package_obj = objects.VnfPackage(context=self.context, **data)
fake_vnf_package_obj = objects.VnfPackage(context=self.context, **data)
obj_primitive = fake_vnf_package_obj.obj_to_primitive('1.0')
obj = vnf_package_obj.obj_from_primitive(obj_primitive)
self.assertIn('size', obj.fields)

View File

@ -526,7 +526,7 @@ class TestController(base.TestCase):
vnf_package_obj = objects.VnfPackage(**vnf_package_dict)
mock_vnf_by_id.return_value = vnf_package_obj
mock_vnf_pack_save.return_value = vnf_package_obj
mock_glance_store.return_value = 'location', 'size', 'checksum',\
mock_glance_store.return_value = 'location', 0, 'checksum',\
'multihash', 'loc_meta'
req = fake_request.HTTPRequest.blank(
'/vnf_packages/%s/package_content'
@ -919,3 +919,27 @@ class TestController(base.TestCase):
self.controller.get_vnf_package_vnfd,
req, constants.UUID)
self.assertEqual(http_client.INTERNAL_SERVER_ERROR, resp.status_code)
def test_fetch_vnf_package_content_valid_range(self):
request = fake_request.HTTPRequest.blank(
'/vnf_packages/%s/package_content/')
request.headers["Range"] = 'bytes=10-99'
range_ = self.controller._get_range_from_request(request, 120)
self.assertEqual(10, range_.start)
self.assertEqual(100, range_.end) # non-inclusive
def test_fetch_vnf_package_content_invalid_range(self):
request = fake_request.HTTPRequest.blank(
'/vnf_packages/%s/package_content/')
request.headers["Range"] = 'bytes=150-'
self.assertRaises(exc.HTTPRequestRangeNotSatisfiable,
self.controller._get_range_from_request,
request, 120)
def test_fetch_vnf_package_content_invalid_multiple_range(self):
request = fake_request.HTTPRequest.blank(
'/vnf_packages/%s/package_content/')
request.headers["Range"] = 'bytes=10-20,21-30'
self.assertRaises(exc.HTTPBadRequest,
self.controller._get_range_from_request, request,
120)