Adds POST /images work that saves image data to a store backend

This commit is contained in:
jaypipes@gmail.com 2010-12-20 13:38:56 -05:00
parent c566d9b77d
commit cff6301bc1
9 changed files with 280 additions and 27 deletions

View File

@ -46,11 +46,6 @@ class ClientConnectionError(Exception):
pass
class BadInputError(Exception):
"""Error resulting from a client sending bad input to a server"""
pass
class ImageBodyIterator(object):
"""
@ -142,17 +137,18 @@ class BaseClient(object):
if status_code == httplib.OK:
return res
elif status_code == httplib.UNAUTHORIZED:
raise exception.NotAuthorized
raise exception.NotAuthorized(res.body)
elif status_code == httplib.FORBIDDEN:
raise exception.NotAuthorized
raise exception.NotAuthorized(res.body)
elif status_code == httplib.NOT_FOUND:
raise exception.NotFound
raise exception.NotFound(res.body)
elif status_code == httplib.CONFLICT:
raise exception.Duplicate
raise exception.Duplicate(res.body)
elif status_code == httplib.BAD_REQUEST:
raise BadInputError
raise exception.BadInputError(res.body)
else:
raise Exception("Unknown error occurred! %d" % status_code)
raise Exception("Unknown error occurred! %d (%s)"
% (status_code, res.body))
except (socket.error, IOError), e:
raise ClientConnectionError("Unable to connect to "
@ -235,17 +231,33 @@ class Client(BaseClient):
"""
Tells Glance about an image's metadata as well
as optionally the image_data itself
:param image_meta: Mapping of information about the
image
:param image_data: Optional string of raw image data
or file-like object that can be
used to read the image data
:retval The newly-stored image's metadata.
"""
if not image_data and 'location' not in image_meta.keys():
raise exception.Invalid("You must either specify a location "
"for the image or supply the actual "
"image data when adding an image to "
"Glance")
body = image_data
headers = util.image_meta_to_http_headers(image_meta)
if image_data:
if hasattr(image_data, 'read'):
# TODO(jaypipes): This is far from efficient. Implement
# chunked transfer encoding if size is not in image_meta
body = image_data.read()
else:
body = image_data
headers['content-type'] = 'application/octet-stream'
else:
body = None
res = self.do_request("POST", "/images", body, headers)
# Registry returns a JSONified dict(image=image_info)
data = json.loads(res.read())
return data['image']['id']

View File

@ -70,6 +70,11 @@ class Invalid(Error):
pass
class BadInputError(Exception):
"""Error resulting from a client sending bad input to a server"""
pass
def wrap_exception(f):
def _wrap(*args, **kw):
try:

View File

@ -173,3 +173,5 @@ DEFINE_string('sql_connection',
'sqlite:///%s/glance.sqlite' % os.path.abspath("./"),
'connection string for sql database')
DEFINE_bool('verbose', False, 'show debug output')
DEFINE_string('default_store', 'file',
'Default storage backend. Default: "file"')

View File

@ -23,6 +23,10 @@ Glance API Server
Configuration Options
---------------------
`default_store`: When no x-image-meta-store header is sent for a
`POST /images` request, this store will be used
for storing the image data. Default: 'file'
"""
import json
@ -39,7 +43,9 @@ from glance.common import flags
from glance.common import wsgi
from glance.store import (get_from_backend,
delete_from_backend,
get_store_from_location)
get_store_from_location,
get_backend_class,
UnsupportedBackend)
from glance import registry
from glance import util
@ -174,9 +180,6 @@ class Controller(wsgi.Controller):
:param request: The WSGI/Webob Request object
:see The `id_type` configuration option (default: uuid) determines
the type of identifier that Glance generates for an image
:raises HTTPBadRequest if no x-image-meta-location is missing
and the request body is not application/octet-stream
image data.
@ -195,7 +198,7 @@ class Controller(wsgi.Controller):
"mime-encoded as application/"
"octet-stream.", request=req)
else:
if 'x-image-meta-store' in headers_keys:
if 'x-image-meta-store' in header_keys:
image_store = req.headers['x-image-meta-store']
image_status = 'pending' # set to available when stored...
image_in_body = True
@ -204,7 +207,15 @@ class Controller(wsgi.Controller):
image_store = get_store_from_location(image_location)
image_status = 'available'
# If image is the request body, validate that the requested
# or default store is capable of storing the image data...
if not image_store:
image_store = FLAGS.default_store
if image_in_body:
store = self.get_store_or_400(req, image_store)
image_meta = util.get_image_meta_from_headers(req)
image_meta['status'] = image_status
image_meta['store'] = image_store
@ -212,15 +223,19 @@ class Controller(wsgi.Controller):
image_meta = registry.add_image_metadata(image_meta)
if image_in_body:
#store = stores.get_store()
#store.add_image(req.body)
try:
location = store.add(image_meta['id'], req.body)
except exception.Duplicate, e:
return HTTPConflict(str(e), request=req)
image_meta['status'] = 'available'
registries.update_image(image_meta)
image_meta['location'] = location
registry.update_image_metadata(image_meta['id'], image_meta)
return dict(image=image_meta)
except exception.Duplicate:
return HTTPConflict()
return HTTPConflict("An image with identifier %s already exists"
% image_meta['id'], request=req)
except exception.Invalid:
return HTTPBadRequest()
@ -275,6 +290,25 @@ class Controller(wsgi.Controller):
request=request,
content_type='text/plain')
def get_store_or_400(self, request, store_name):
"""
Grabs the storage backend for the supplied store name
or raises an HTTPBadRequest (400) response
:param request: The WSGI/Webob Request object
:param id: The opaque image identifier
:raises HTTPNotFound if image does not exist
"""
try:
return get_backend_class(store_name)
except UnsupportedBackend:
raise HTTPBadRequest(body='Requested store %s not available '
'for storage on this Glance node'
% store_name,
request=request,
content_type='text/plain')
class API(wsgi.Router):

View File

@ -66,7 +66,7 @@ def get_backend_class(backend):
try:
return BACKENDS[backend]
except KeyError:
raise UnsupportedBackend("No backend found for '%s'" % scheme)
raise UnsupportedBackend("No backend found for '%s'" % backend)
def get_from_backend(uri, **kwargs):

View File

@ -26,6 +26,11 @@ from glance.common import exception
from glance.common import flags
import glance.store
flags.DEFINE_string('filesystem_store_datadir', '/var/lib/glance/images/',
'Location to write image data. '
'Default: /var/lib/glance/images/')
FLAGS = flags.FLAGS
@ -94,4 +99,30 @@ class FilesystemBackend(glance.store.Backend):
except OSError:
raise exception.NotAuthorized("You cannot delete file %s" % fn)
else:
raise exception.NotFound("Image file %s does not exist" % fn)
raise exception.NotFound("Image file %s does not exist" % fn)
@classmethod
def add(cls, id, data):
"""
Stores image data to disk and returns a location that the image was
written to. By default, the backend writes the image data to a file
`/<DATADIR>/<ID>`, where <DATADIR> is the value of
FLAGS.filesystem_store_datadir and <ID> is the supplied image ID.
:param id: The opaque image identifier
:param data: The image data to write
:retval The location that was written, with file:// scheme prepended
"""
filepath = os.path.join(FLAGS.filesystem_store_datadir, str(id))
if os.path.exists(filepath):
raise exception.Duplicate("Image file %s already exists!"
% filepath)
f = open(filepath, 'wb')
f.write(data)
f.close()
return 'file://%s' % filepath

View File

@ -37,7 +37,7 @@ import glance.store.swift
import glance.registry.db.sqlalchemy.api
FAKE_FILESYSTEM_ROOTDIR = os.path.join('//tmp', 'glance-tests')
FAKE_FILESYSTEM_ROOTDIR = os.path.join('/tmp', 'glance-tests')
def stub_out_http_backend(stubs):
@ -356,6 +356,22 @@ def stub_out_registry_db_image_api(stubs):
return values
def image_update(self, _context, image_id, values):
props = []
if 'properties' in values.keys():
for k,v in values['properties'].iteritems():
p = {}
p['key'] = k
p['value'] = v
p['deleted'] = False
p['created_at'] = datetime.datetime.utcnow()
p['updated_at'] = datetime.datetime.utcnow()
p['deleted_at'] = None
props.append(p)
values['properties'] = props
image = self.image_get(_context, image_id)
image.update(values)
return image

View File

@ -22,11 +22,14 @@ import stubout
import webob
from glance import server
from glance.common import flags
from glance.registry import server as rserver
from tests import stubs
FLAGS = flags.FLAGS
class TestImageController(unittest.TestCase):
class TestRegistryAPI(unittest.TestCase):
def setUp(self):
"""Establish a clean test environment"""
self.stubs = stubout.StubOutForTesting()
@ -230,7 +233,71 @@ class TestImageController(unittest.TestCase):
self.assertEquals(res.status_int,
webob.exc.HTTPNotFound.code)
class TestGlanceAPI(unittest.TestCase):
def setUp(self):
"""Establish a clean test environment"""
self.stubs = stubout.StubOutForTesting()
stubs.stub_out_registry_and_store_server(self.stubs)
stubs.stub_out_registry_db_image_api(self.stubs)
stubs.stub_out_filesystem_backend()
self.orig_filesystem_store_datadir = FLAGS.filesystem_store_datadir
FLAGS.filesystem_store_datadir = stubs.FAKE_FILESYSTEM_ROOTDIR
def tearDown(self):
"""Clear the test environment"""
FLAGS.filesystem_store_datadir = self.orig_filesystem_store_datadir
stubs.clean_out_fake_filesystem_backend()
self.stubs.UnsetAll()
def test_add_image_no_location_no_image_as_body(self):
"""Tests raises BadRequest for no body and no loc header"""
fixture_headers = {'x-image-meta-store': 'file',
'x-image-meta-name': 'fake image #3'}
req = webob.Request.blank("/images")
req.method = 'POST'
for k,v in fixture_headers.iteritems():
req.headers[k] = v
res = req.get_response(server.API())
self.assertEquals(res.status_int, webob.exc.HTTPBadRequest.code)
def test_add_image_bad_store(self):
"""Tests raises BadRequest for invalid store header"""
fixture_headers = {'x-image-meta-store': 'bad',
'x-image-meta-name': 'fake image #3'}
req = webob.Request.blank("/images")
req.method = 'POST'
for k,v in fixture_headers.iteritems():
req.headers[k] = v
req.headers['Content-Type'] = 'application/octet-stream'
req.body = "chunk00000remainder"
res = req.get_response(server.API())
self.assertEquals(res.status_int, webob.exc.HTTPBadRequest.code)
def test_add_image_basic_file_store(self):
"""Tests raises BadRequest for invalid store header"""
fixture_headers = {'x-image-meta-store': 'file',
'x-image-meta-name': 'fake image #3'}
req = webob.Request.blank("/images")
req.method = 'POST'
for k,v in fixture_headers.iteritems():
req.headers[k] = v
req.headers['Content-Type'] = 'application/octet-stream'
req.body = "chunk00000remainder"
res = req.get_response(server.API())
self.assertEquals(res.status_int, 200)
res_body = json.loads(res.body)['image']
self.assertEquals(res_body['location'],
'file:///tmp/glance-tests/3')
def test_image_meta(self):
"""Test for HEAD /images/<ID>"""
expected_headers = {'x-image-meta-id': 2,
'x-image-meta-name': 'fake image #2'}
req = webob.Request.blank("/images/2")

View File

@ -16,6 +16,7 @@
# under the License.
import json
import os
import stubout
import StringIO
import unittest
@ -24,9 +25,12 @@ import webob
from glance import client
from glance.registry import client as rclient
from glance.common import flags
from glance.common import exception
from tests import stubs
FLAGS = flags.FLAGS
class TestBadClients(unittest.TestCase):
@ -212,7 +216,7 @@ class TestRegistryClient(unittest.TestCase):
'location': "file:///tmp/glance-tests/2",
}
self.assertRaises(client.BadInputError,
self.assertRaises(exception.BadInputError,
self.client.add_image,
fixture)
@ -279,10 +283,13 @@ class TestClient(unittest.TestCase):
stubs.stub_out_registry_db_image_api(self.stubs)
stubs.stub_out_registry_and_store_server(self.stubs)
stubs.stub_out_filesystem_backend()
self.orig_filesystem_store_datadir = FLAGS.filesystem_store_datadir
FLAGS.filesystem_store_datadir = stubs.FAKE_FILESYSTEM_ROOTDIR
self.client = client.Client()
def tearDown(self):
"""Clear the test environment"""
FLAGS.filesystem_store_datadir = self.orig_filesystem_store_datadir
stubs.clean_out_fake_filesystem_backend()
self.stubs.UnsetAll()
@ -480,6 +487,85 @@ class TestClient(unittest.TestCase):
self.assertEquals(data['status'], 'available')
def test_add_image_with_image_data_as_string(self):
"""Tests can add image by passing image data as string"""
fixture = {'name': 'fake public image',
'is_public': True,
'type': 'kernel',
'size': 19,
'properties': {'distro': 'Ubuntu 10.04 LTS'}
}
image_data_fixture = r"chunk0000remainder"
new_id = self.client.add_image(fixture, image_data_fixture)
self.assertEquals(3, new_id)
new_meta, new_image_chunks = self.client.get_image(3)
new_image_data = ""
for image_chunk in new_image_chunks:
new_image_data += image_chunk
self.assertEquals(image_data_fixture, new_image_data)
for k,v in fixture.iteritems():
self.assertEquals(v, new_meta[k])
def test_add_image_with_image_data_as_file(self):
"""Tests can add image by passing image data as file"""
fixture = {'name': 'fake public image',
'is_public': True,
'type': 'kernel',
'size': 19,
'properties': {'distro': 'Ubuntu 10.04 LTS'}
}
image_data_fixture = r"chunk0000remainder"
tmp_image_filepath = '/tmp/rubbish-image'
if os.path.exists(tmp_image_filepath):
os.unlink(tmp_image_filepath)
tmp_file = open(tmp_image_filepath, 'wb')
tmp_file.write(image_data_fixture)
tmp_file.close()
new_id = self.client.add_image(fixture, open(tmp_image_filepath))
self.assertEquals(3, new_id)
if os.path.exists(tmp_image_filepath):
os.unlink(tmp_image_filepath)
new_meta, new_image_chunks = self.client.get_image(3)
new_image_data = ""
for image_chunk in new_image_chunks:
new_image_data += image_chunk
self.assertEquals(image_data_fixture, new_image_data)
for k,v in fixture.iteritems():
self.assertEquals(v, new_meta[k])
def test_add_image_with_bad_store(self):
"""Tests BadRequest raised when supplying bad store name in meta"""
fixture = {'name': 'fake public image',
'is_public': True,
'type': 'kernel',
'size': 19,
'store': 'bad',
'properties': {'distro': 'Ubuntu 10.04 LTS'}
}
image_data_fixture = r"chunk0000remainder"
self.assertRaises(exception.BadInputError,
self.client.add_image,
fixture,
image_data_fixture)
def test_update_image(self):
"""Tests that the /images PUT registry API updates the image"""
fixture = {'name': 'fake public image #2',