diff --git a/glance/api/v2/image_data.py b/glance/api/v2/image_data.py new file mode 100644 index 0000000000..2fffbc7a5c --- /dev/null +++ b/glance/api/v2/image_data.py @@ -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) diff --git a/glance/api/v2/images.py b/glance/api/v2/images.py index e98f42ca92..025b547eaa 100644 --- a/glance/api/v2/images.py +++ b/glance/api/v2/images.py @@ -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'}, ] diff --git a/glance/api/v2/router.py b/glance/api/v2/router.py index c028a3e22a..96de8ab516 100644 --- a/glance/api/v2/router.py +++ b/glance/api/v2/router.py @@ -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) diff --git a/glance/store/__init__.py b/glance/store/__init__.py index a6729a8a3a..c21f6e2cc5 100644 --- a/glance/store/__init__.py +++ b/glance/store/__init__.py @@ -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) diff --git a/glance/tests/functional/v2/test_images.py b/glance/tests/functional/v2/test_images.py index 85a7556e30..62eaed4163 100644 --- a/glance/tests/functional/v2/test_images.py +++ b/glance/tests/functional/v2/test_images.py @@ -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()) diff --git a/glance/tests/unit/utils.py b/glance/tests/unit/utils.py index b596085279..d61187a18f 100644 --- a/glance/tests/unit/utils.py +++ b/glance/tests/unit/utils.py @@ -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) diff --git a/glance/tests/unit/v2/test_image_data_resource.py b/glance/tests/unit/v2/test_image_data_resource.py new file mode 100644 index 0000000000..e026b45dd7 --- /dev/null +++ b/glance/tests/unit/v2/test_image_data_resource.py @@ -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']) diff --git a/glance/tests/unit/v2/test_images_resource.py b/glance/tests/unit/v2/test_images_resource.py index c35def0ad7..85424b1ea1 100644 --- a/glance/tests/unit/v2/test_images_resource.py +++ b/glance/tests/unit/v2/test_images_resource.py @@ -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'} ], },