Merge "Implement image data upload/download for v2 API"
This commit is contained in:
commit
5e85329ed1
78
glance/api/v2/image_data.py
Normal file
78
glance/api/v2/image_data.py
Normal file
@ -0,0 +1,78 @@
|
||||
# Copyright 2012 OpenStack LLC.
|
||||
# All Rights Reserved.
|
||||
#
|
||||
# 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 webob.exc
|
||||
|
||||
from glance.api.v2 import base
|
||||
from glance.common import exception
|
||||
from glance.common import wsgi
|
||||
import glance.registry.db.api
|
||||
import glance.store
|
||||
import glance.store.filesystem
|
||||
import glance.store.http
|
||||
import glance.store.rbd
|
||||
import glance.store.s3
|
||||
import glance.store.swift
|
||||
|
||||
|
||||
class ImageDataController(base.Controller):
|
||||
def __init__(self, conf, db_api=None, store_api=None):
|
||||
super(ImageDataController, self).__init__(conf)
|
||||
self.db_api = db_api or glance.registry.db.api
|
||||
self.db_api.configure_db(conf)
|
||||
self.store_api = store_api or glance.store
|
||||
self.store_api.create_stores(conf)
|
||||
|
||||
def _get_image(self, context, image_id):
|
||||
try:
|
||||
return self.db_api.image_get(context, image_id)
|
||||
except exception.NotFound:
|
||||
raise webob.exc.HTTPNotFound(_("Image does not exist"))
|
||||
|
||||
def upload(self, req, image_id, data):
|
||||
self._get_image(req.context, image_id)
|
||||
size = len(data)
|
||||
location, size, checksum = self.store_api.add_to_backend(
|
||||
'file', image_id, data, size)
|
||||
self.db_api.image_update(req.context, image_id, {'location': location})
|
||||
|
||||
def download(self, req, image_id):
|
||||
image = self._get_image(req.context, image_id)
|
||||
location = image['location']
|
||||
if location:
|
||||
image_data, image_size = self.store_api.get_from_backend(location)
|
||||
return {'data': image_data, 'size': image_size}
|
||||
else:
|
||||
raise webob.exc.HTTPNotFound(_("No image data could be found"))
|
||||
|
||||
|
||||
class RequestDeserializer(wsgi.JSONRequestDeserializer):
|
||||
def upload(self, request):
|
||||
return {'data': request.body}
|
||||
|
||||
|
||||
class ResponseSerializer(wsgi.JSONResponseSerializer):
|
||||
def download(self, response, result):
|
||||
response.headers['Content-Length'] = result['size']
|
||||
response.headers['Content-Type'] = 'application/octet-stream'
|
||||
response.app_iter = result['data']
|
||||
|
||||
|
||||
def create_resource(conf):
|
||||
"""Image data resource factory method"""
|
||||
deserializer = RequestDeserializer()
|
||||
serializer = ResponseSerializer()
|
||||
controller = ImageDataController(conf)
|
||||
return wsgi.Resource(controller, deserializer, serializer)
|
@ -26,9 +26,9 @@ import glance.registry.db.api
|
||||
|
||||
|
||||
class ImagesController(base.Controller):
|
||||
def __init__(self, conf, db=None):
|
||||
def __init__(self, conf, db_api=None):
|
||||
super(ImagesController, self).__init__(conf)
|
||||
self.db_api = db or glance.registry.db.api
|
||||
self.db_api = db_api or glance.registry.db.api
|
||||
self.db_api.configure_db(conf)
|
||||
|
||||
def create(self, req, image):
|
||||
@ -86,17 +86,20 @@ class RequestDeserializer(wsgi.JSONRequestDeserializer):
|
||||
body = output.pop('body')
|
||||
self._validate(request, body)
|
||||
output['image'] = body
|
||||
|
||||
return output
|
||||
|
||||
|
||||
class ResponseSerializer(wsgi.JSONResponseSerializer):
|
||||
def _get_image_href(self, image):
|
||||
return '/v2/images/%s' % image['id']
|
||||
def _get_image_href(self, image, subcollection=''):
|
||||
base_href = '/v2/images/%s' % image['id']
|
||||
if subcollection:
|
||||
base_href = '%s/%s' % (base_href, subcollection)
|
||||
return base_href
|
||||
|
||||
def _get_image_links(self, image):
|
||||
return [
|
||||
{'rel': 'self', 'href': self._get_image_href(image)},
|
||||
{'rel': 'file', 'href': self._get_image_href(image, 'file')},
|
||||
{'rel': 'describedby', 'href': '/v2/schemas/image'},
|
||||
]
|
||||
|
||||
|
@ -19,6 +19,7 @@ import logging
|
||||
|
||||
import routes
|
||||
|
||||
from glance.api.v2 import image_data
|
||||
from glance.api.v2 import images
|
||||
from glance.api.v2 import root
|
||||
from glance.api.v2 import schemas
|
||||
@ -74,4 +75,14 @@ class API(wsgi.Router):
|
||||
action='delete',
|
||||
conditions={'method': ['DELETE']})
|
||||
|
||||
image_data_resource = image_data.create_resource(conf)
|
||||
mapper.connect('/images/{image_id}/file',
|
||||
controller=image_data_resource,
|
||||
action='download',
|
||||
conditions={'method': ['GET']})
|
||||
mapper.connect('/images/{image_id}/file',
|
||||
controller=image_data_resource,
|
||||
action='upload',
|
||||
conditions={'method': ['PUT']})
|
||||
|
||||
super(API, self).__init__(mapper)
|
||||
|
@ -279,3 +279,8 @@ def schedule_delete_from_backend(uri, conf, context, image_id, **kwargs):
|
||||
|
||||
registry.update_image_metadata(context, image_id,
|
||||
{'status': 'pending_delete'})
|
||||
|
||||
|
||||
def add_to_backend(scheme, image_id, data, size):
|
||||
store = get_store_from_scheme(scheme)
|
||||
return store.add(image_id, data, size)
|
||||
|
@ -96,6 +96,25 @@ class TestImages(functional.FunctionalTest):
|
||||
self.assertEqual(image_id, image['id'])
|
||||
self.assertEqual('image-2', image['name'])
|
||||
|
||||
# Try to download data before its uploaded
|
||||
path = self._url('/v2/images/%s/file' % image_id)
|
||||
headers = self._headers()
|
||||
response = requests.get(path, headers=headers)
|
||||
self.assertEqual(404, response.status_code)
|
||||
|
||||
# Upload some image data
|
||||
path = self._url('/v2/images/%s/file' % image_id)
|
||||
headers = self._headers()
|
||||
response = requests.put(path, headers=headers, data='ZZZZZ')
|
||||
self.assertEqual(200, response.status_code)
|
||||
|
||||
# Try to download the data that was just uploaded
|
||||
path = self._url('/v2/images/%s/file' % image_id)
|
||||
headers = self._headers()
|
||||
response = requests.get(path, headers=headers)
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertEqual(response.text, 'ZZZZZ')
|
||||
|
||||
# Deletion should work
|
||||
path = self._url('/v2/images/%s' % image_id)
|
||||
response = requests.delete(path, headers=self._headers())
|
||||
@ -106,6 +125,12 @@ class TestImages(functional.FunctionalTest):
|
||||
response = requests.get(path, headers=self._headers())
|
||||
self.assertEqual(404, response.status_code)
|
||||
|
||||
# And neither should its data
|
||||
path = self._url('/v2/images/%s/file' % image_id)
|
||||
headers = self._headers()
|
||||
response = requests.get(path, headers=headers)
|
||||
self.assertEqual(404, response.status_code)
|
||||
|
||||
# Image list should now be empty
|
||||
path = self._url('/v2/images')
|
||||
response = requests.get(path, headers=self._headers())
|
||||
|
@ -20,6 +20,7 @@ import webob
|
||||
|
||||
import glance.common.context
|
||||
from glance.common import exception
|
||||
from glance.common import utils
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
@ -51,7 +52,7 @@ class FakeDB(object):
|
||||
|
||||
def __init__(self):
|
||||
self.images = {
|
||||
UUID1: self._image_format(UUID1),
|
||||
UUID1: self._image_format(UUID1, location='1'),
|
||||
UUID2: self._image_format(UUID2),
|
||||
}
|
||||
self.members = {
|
||||
@ -77,7 +78,13 @@ class FakeDB(object):
|
||||
}
|
||||
|
||||
def _image_format(self, image_id, **values):
|
||||
image = {'id': image_id, 'name': 'image-name'}
|
||||
image = {
|
||||
'id': image_id,
|
||||
'name': 'image-name',
|
||||
'owner': TENANT1,
|
||||
'location': None,
|
||||
'status': 'queued',
|
||||
}
|
||||
image.update(values)
|
||||
return image
|
||||
|
||||
@ -137,3 +144,31 @@ class FakeDB(object):
|
||||
self.images[image_id] = image
|
||||
LOG.info('Image %s updated to %s' % (image_id, str(image)))
|
||||
return image
|
||||
|
||||
|
||||
class FakeStoreAPI(object):
|
||||
def __init__(self):
|
||||
self.data = {
|
||||
'1': ('XXX', 3),
|
||||
'2': ('FFFFF', 5),
|
||||
}
|
||||
|
||||
def create_stores(self, conf):
|
||||
pass
|
||||
|
||||
def get_from_backend(self, location):
|
||||
try:
|
||||
#NOTE(bcwaldon): This fake API is store-agnostic, so we only
|
||||
# care about location being some unique string
|
||||
return self.data[location]
|
||||
except KeyError:
|
||||
raise exception.NotFound()
|
||||
|
||||
def get_size_from_backend(self, location):
|
||||
return self.get_from_backend(location)[1]
|
||||
|
||||
def add_to_backend(self, scheme, image_id, data, size):
|
||||
location = utils.generate_uuid()
|
||||
self.data[location] = (data, size)
|
||||
checksum = 'Z'
|
||||
return (location, size, checksum)
|
||||
|
89
glance/tests/unit/v2/test_image_data_resource.py
Normal file
89
glance/tests/unit/v2/test_image_data_resource.py
Normal file
@ -0,0 +1,89 @@
|
||||
# Copyright 2012 OpenStack LLC.
|
||||
# All Rights Reserved.
|
||||
#
|
||||
# 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 unittest
|
||||
|
||||
import webob
|
||||
|
||||
import glance.api.v2.image_data
|
||||
from glance.common import utils
|
||||
import glance.tests.unit.utils as test_utils
|
||||
import glance.tests.utils
|
||||
|
||||
|
||||
class TestImagesController(unittest.TestCase):
|
||||
def setUp(self):
|
||||
super(TestImagesController, self).setUp()
|
||||
|
||||
conf = glance.tests.utils.TestConfigOpts({
|
||||
'verbose': True,
|
||||
'debug': True,
|
||||
})
|
||||
self.controller = glance.api.v2.image_data.ImageDataController(conf,
|
||||
db_api=test_utils.FakeDB(),
|
||||
store_api=test_utils.FakeStoreAPI())
|
||||
|
||||
def test_download(self):
|
||||
request = test_utils.FakeRequest()
|
||||
output = self.controller.download(request, test_utils.UUID1)
|
||||
expected = {'data': 'XXX', 'size': 3}
|
||||
self.assertEqual(expected, output)
|
||||
|
||||
def test_download_no_data(self):
|
||||
request = test_utils.FakeRequest()
|
||||
self.assertRaises(webob.exc.HTTPNotFound, self.controller.download,
|
||||
request, test_utils.UUID2)
|
||||
|
||||
def test_download_non_existant_image(self):
|
||||
request = test_utils.FakeRequest()
|
||||
self.assertRaises(webob.exc.HTTPNotFound, self.controller.download,
|
||||
request, utils.generate_uuid())
|
||||
|
||||
def test_upload_download(self):
|
||||
request = test_utils.FakeRequest()
|
||||
self.controller.upload(request, test_utils.UUID2, 'YYYY')
|
||||
output = self.controller.download(request, test_utils.UUID2)
|
||||
expected = {'data': 'YYYY', 'size': 4}
|
||||
self.assertEqual(expected, output)
|
||||
|
||||
def test_upload_non_existant_image(self):
|
||||
request = test_utils.FakeRequest()
|
||||
self.assertRaises(webob.exc.HTTPNotFound, self.controller.upload,
|
||||
request, utils.generate_uuid(), 'YYYY')
|
||||
|
||||
|
||||
class TestImageDataDeserializer(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.deserializer = glance.api.v2.image_data.RequestDeserializer()
|
||||
|
||||
def test_upload(self):
|
||||
request = test_utils.FakeRequest()
|
||||
request.body = 'YYY'
|
||||
output = self.deserializer.upload(request)
|
||||
expected = {'data': 'YYY'}
|
||||
self.assertEqual(expected, output)
|
||||
|
||||
|
||||
class TestImageDataSerializer(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.serializer = glance.api.v2.image_data.ResponseSerializer()
|
||||
|
||||
def test_download(self):
|
||||
response = webob.Response()
|
||||
self.serializer.download(response, {'data': 'ZZZ', 'size': 3})
|
||||
self.assertEqual('ZZZ', response.body)
|
||||
self.assertEqual('3', response.headers['Content-Length'])
|
||||
self.assertEqual('application/octet-stream',
|
||||
response.headers['Content-Type'])
|
@ -57,7 +57,13 @@ class TestImagesController(unittest.TestCase):
|
||||
image = {'name': 'image-1'}
|
||||
output = self.controller.create(request, image)
|
||||
output.pop('id')
|
||||
self.assertEqual(image, output)
|
||||
expected = {
|
||||
'name': 'image-1',
|
||||
'owner': test_utils.TENANT1,
|
||||
'location': None,
|
||||
'status': 'queued',
|
||||
}
|
||||
self.assertEqual(expected, output)
|
||||
|
||||
def test_create_with_owner_forbidden(self):
|
||||
request = test_utils.FakeRequest()
|
||||
@ -70,7 +76,13 @@ class TestImagesController(unittest.TestCase):
|
||||
image = {'name': 'image-2'}
|
||||
output = self.controller.update(request, test_utils.UUID1, image)
|
||||
output.pop('id')
|
||||
self.assertEqual(image, output)
|
||||
expected = {
|
||||
'name': 'image-2',
|
||||
'owner': test_utils.TENANT1,
|
||||
'location': '1',
|
||||
'status': 'queued',
|
||||
}
|
||||
self.assertEqual(expected, output)
|
||||
|
||||
def test_update_non_existant(self):
|
||||
request = test_utils.FakeRequest()
|
||||
@ -139,6 +151,10 @@ class TestImagesSerializer(unittest.TestCase):
|
||||
'rel': 'self',
|
||||
'href': '/v2/images/%s' % test_utils.UUID1,
|
||||
},
|
||||
{
|
||||
'rel': 'file',
|
||||
'href': '/v2/images/%s/file' % test_utils.UUID1,
|
||||
},
|
||||
{'rel': 'describedby', 'href': '/v2/schemas/image'}
|
||||
],
|
||||
},
|
||||
@ -150,6 +166,10 @@ class TestImagesSerializer(unittest.TestCase):
|
||||
'rel': 'self',
|
||||
'href': '/v2/images/%s' % test_utils.UUID2,
|
||||
},
|
||||
{
|
||||
'rel': 'file',
|
||||
'href': '/v2/images/%s/file' % test_utils.UUID2,
|
||||
},
|
||||
{'rel': 'describedby', 'href': '/v2/schemas/image'}
|
||||
],
|
||||
},
|
||||
@ -171,6 +191,10 @@ class TestImagesSerializer(unittest.TestCase):
|
||||
'rel': 'self',
|
||||
'href': '/v2/images/%s' % test_utils.UUID2,
|
||||
},
|
||||
{
|
||||
'rel': 'file',
|
||||
'href': '/v2/images/%s/file' % test_utils.UUID2,
|
||||
},
|
||||
{'rel': 'describedby', 'href': '/v2/schemas/image'}
|
||||
],
|
||||
},
|
||||
@ -187,10 +211,8 @@ class TestImagesSerializer(unittest.TestCase):
|
||||
'id': test_utils.UUID2,
|
||||
'name': 'image-2',
|
||||
'links': [
|
||||
{
|
||||
'rel': 'self',
|
||||
'href': self_link,
|
||||
},
|
||||
{'rel': 'self', 'href': self_link},
|
||||
{'rel': 'file', 'href': '%s/file' % self_link},
|
||||
{'rel': 'describedby', 'href': '/v2/schemas/image'}
|
||||
],
|
||||
},
|
||||
@ -208,10 +230,8 @@ class TestImagesSerializer(unittest.TestCase):
|
||||
'id': test_utils.UUID2,
|
||||
'name': 'image-2',
|
||||
'links': [
|
||||
{
|
||||
'rel': 'self',
|
||||
'href': self_link,
|
||||
},
|
||||
{'rel': 'self', 'href': self_link},
|
||||
{'rel': 'file', 'href': '%s/file' % self_link},
|
||||
{'rel': 'describedby', 'href': '/v2/schemas/image'}
|
||||
],
|
||||
},
|
||||
|
Loading…
Reference in New Issue
Block a user