Adds checksumming to Glance.
When adding an image (or uploading an image during PUT operations), you may now supply an optional X-Image-Meta-Checksum header. When storing the uploaded image, the backend image stores now are required to return a checksum of the data they just stored. The optional X-Image-Meta-Checksum header is compared against this generated checksum and returns a 409 Bad Request if there is a mismatch. The ETag header is now properly set to the image's checksum now for all GET /images/<ID>, HEAD /images/<ID>, POST /images and PUT /images/<ID> operations. Adds unit tests verifying the checksumming behaviour in the API, and in the Swift and Filesystem backend stores.
This commit is contained in:
parent
a3690f0c9a
commit
af11621170
@ -67,6 +67,7 @@ JSON-encoded mapping in the following format::
|
||||
'disk_format': 'vhd',
|
||||
'container_format': 'ovf',
|
||||
'size': '5368709120',
|
||||
'checksum': 'c2e5db72bd7fd153f53ede5da5a06de3',
|
||||
'location': 'swift://account:key/container/image.tar.gz.0',
|
||||
'created_at': '2010-02-03 09:34:01',
|
||||
'updated_at': '2010-02-03 09:34:01',
|
||||
@ -116,6 +117,7 @@ following shows an example of the HTTP headers returned from the above
|
||||
x-image-meta-disk-format vhd
|
||||
x-image-meta-container-format ovf
|
||||
x-image-meta-size 5368709120
|
||||
x-image-meta-checksum c2e5db72bd7fd153f53ede5da5a06de3
|
||||
x-image-meta-location swift://account:key/container/image.tar.gz.0
|
||||
x-image-meta-created_at 2010-02-03 09:34:01
|
||||
x-image-meta-updated_at 2010-02-03 09:34:01
|
||||
@ -137,6 +139,9 @@ following shows an example of the HTTP headers returned from the above
|
||||
that have been saved with the image metadata. The key is the string
|
||||
after `x-image-meta-property-` and the value is the value of the header
|
||||
|
||||
The response's `ETag` header will always be equal to the
|
||||
`x-image-meta-checksum` value
|
||||
|
||||
|
||||
Retrieving a Virtual Machine Image
|
||||
----------------------------------
|
||||
@ -166,6 +171,7 @@ returned from the above ``GET`` request::
|
||||
x-image-meta-disk-format vhd
|
||||
x-image-meta-container-format ovf
|
||||
x-image-meta-size 5368709120
|
||||
x-image-meta-checksum c2e5db72bd7fd153f53ede5da5a06de3
|
||||
x-image-meta-location swift://account:key/container/image.tar.gz.0
|
||||
x-image-meta-created_at 2010-02-03 09:34:01
|
||||
x-image-meta-updated_at 2010-02-03 09:34:01
|
||||
@ -190,6 +196,9 @@ returned from the above ``GET`` request::
|
||||
The response's `Content-Length` header shall be equal to the value of
|
||||
the `x-image-meta-size` header
|
||||
|
||||
The response's `ETag` header will always be equal to the
|
||||
`x-image-meta-checksum` value
|
||||
|
||||
The image data itself will be the body of the HTTP response returned
|
||||
from the request, which will have content-type of
|
||||
`application/octet-stream`.
|
||||
@ -284,6 +293,14 @@ The list of metadata headers that Glance accepts are listed below.
|
||||
When not present, Glance will calculate the image's size based on the size
|
||||
of the request body.
|
||||
|
||||
* ``x-image-meta-checksum``
|
||||
|
||||
This header is optional.
|
||||
|
||||
When present, Glance will verify the checksum generated from the backend
|
||||
store when storing your image against this value and return a
|
||||
**400 Bad Request** if the values do not match.
|
||||
|
||||
* ``x-image-meta-is-public``
|
||||
|
||||
This header is optional.
|
||||
|
@ -142,7 +142,10 @@ class BaseClient(object):
|
||||
c.request(method, action, body, headers)
|
||||
res = c.getresponse()
|
||||
status_code = self.get_status_code(res)
|
||||
if status_code == httplib.OK:
|
||||
if status_code in (httplib.OK,
|
||||
httplib.CREATED,
|
||||
httplib.ACCEPTED,
|
||||
httplib.NO_CONTENT):
|
||||
return res
|
||||
elif status_code == httplib.UNAUTHORIZED:
|
||||
raise exception.NotAuthorized
|
||||
|
@ -42,7 +42,7 @@ BASE_MODEL_ATTRS = set(['id', 'created_at', 'updated_at', 'deleted_at',
|
||||
|
||||
IMAGE_ATTRS = BASE_MODEL_ATTRS | set(['name', 'status', 'size',
|
||||
'disk_format', 'container_format',
|
||||
'is_public', 'location'])
|
||||
'is_public', 'location', 'checksum'])
|
||||
|
||||
CONTAINER_FORMATS = ['ami', 'ari', 'aki', 'bare', 'ovf']
|
||||
DISK_FORMATS = ['ami', 'ari', 'aki', 'vhd', 'vmdk', 'raw', 'qcow2', 'vdi']
|
||||
|
@ -104,6 +104,7 @@ class Image(BASE, ModelBase):
|
||||
status = Column(String(30), nullable=False)
|
||||
is_public = Column(Boolean, nullable=False, default=False)
|
||||
location = Column(Text)
|
||||
checksum = Column(String(32))
|
||||
|
||||
|
||||
class ImageProperty(BASE, ModelBase):
|
||||
|
@ -31,6 +31,8 @@ from glance.registry.db import api as db_api
|
||||
|
||||
logger = logging.getLogger('glance.registry.server')
|
||||
|
||||
DISPLAY_FIELDS_IN_INDEX = ['id', 'name', 'size', 'checksum']
|
||||
|
||||
|
||||
class Controller(wsgi.Controller):
|
||||
"""Controller for the reference implementation registry server"""
|
||||
@ -49,14 +51,22 @@ class Controller(wsgi.Controller):
|
||||
|
||||
Where image_list is a sequence of mappings::
|
||||
|
||||
{'id': image_id, 'name': image_name}
|
||||
{
|
||||
'id': <ID>,
|
||||
'name': <NAME>,
|
||||
'size': <SIZE>,
|
||||
'checksum': <CHECKSUM>
|
||||
}
|
||||
|
||||
"""
|
||||
images = db_api.image_get_all_public(None)
|
||||
image_dicts = [dict(id=i['id'],
|
||||
name=i['name'],
|
||||
size=i['size']) for i in images]
|
||||
return dict(images=image_dicts)
|
||||
results = []
|
||||
for image in images:
|
||||
result = {}
|
||||
for field in DISPLAY_FIELDS_IN_INDEX:
|
||||
result[field] = image[field]
|
||||
results.append(result)
|
||||
return dict(images=results)
|
||||
|
||||
def detail(self, req):
|
||||
"""Return detailed information for all public, non-deleted images
|
||||
|
106
glance/server.py
106
glance/server.py
@ -29,6 +29,7 @@ Configuration Options
|
||||
|
||||
"""
|
||||
|
||||
import httplib
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
@ -68,8 +69,8 @@ class Controller(wsgi.Controller):
|
||||
GET /images/<ID> -- Return image data for image with id <ID>
|
||||
POST /images -- Store image data and return metadata about the
|
||||
newly-stored image
|
||||
PUT /images/<ID> -- Update image metadata (not image data, since
|
||||
image data is immutable once stored)
|
||||
PUT /images/<ID> -- Update image metadata and/or upload image
|
||||
data for a previously-reserved image
|
||||
DELETE /images/<ID> -- Delete the image with id <ID>
|
||||
"""
|
||||
|
||||
@ -90,7 +91,9 @@ class Controller(wsgi.Controller):
|
||||
{'images': [
|
||||
{'id': <ID>,
|
||||
'name': <NAME>,
|
||||
'size': <SIZE>}, ...
|
||||
'size': <SIZE>,
|
||||
'checksum': <CHECKSUM>
|
||||
}, ...
|
||||
]}
|
||||
"""
|
||||
images = registry.get_images_list(self.options)
|
||||
@ -109,6 +112,7 @@ class Controller(wsgi.Controller):
|
||||
'size': <SIZE>,
|
||||
'disk_format': <DISK_FORMAT>,
|
||||
'container_format': <CONTAINER_FORMAT>,
|
||||
'checksum': <CHECKSUM>,
|
||||
'store': <STORE>,
|
||||
'status': <STATUS>,
|
||||
'created_at': <TIMESTAMP>,
|
||||
@ -134,6 +138,8 @@ class Controller(wsgi.Controller):
|
||||
|
||||
res = Response(request=req)
|
||||
utils.inject_image_meta_into_headers(res, image)
|
||||
res.headers.add('Location', "/images/%s" % id)
|
||||
res.headers.add('ETag', image['checksum'])
|
||||
|
||||
return req.get_response(res)
|
||||
|
||||
@ -163,6 +169,8 @@ class Controller(wsgi.Controller):
|
||||
res = Response(app_iter=image_iterator(),
|
||||
content_type="text/plain")
|
||||
utils.inject_image_meta_into_headers(res, image)
|
||||
res.headers.add('Location', "/images/%s" % id)
|
||||
res.headers.add('ETag', image['checksum'])
|
||||
return req.get_response(res)
|
||||
|
||||
def _reserve(self, req):
|
||||
@ -223,36 +231,45 @@ class Controller(wsgi.Controller):
|
||||
|
||||
store = self.get_store_or_400(req, store_name)
|
||||
|
||||
image_meta['status'] = 'saving'
|
||||
image_id = image_meta['id']
|
||||
logger.debug("Updating image metadata for image %s"
|
||||
logger.debug("Setting image %s to status 'saving'"
|
||||
% image_id)
|
||||
registry.update_image_metadata(self.options,
|
||||
image_meta['id'],
|
||||
image_meta)
|
||||
|
||||
registry.update_image_metadata(self.options, image_id,
|
||||
{'status': 'saving'})
|
||||
try:
|
||||
logger.debug("Uploading image data for image %(image_id)s "
|
||||
"to %(store_name)s store" % locals())
|
||||
location, size = store.add(image_meta['id'],
|
||||
location, size, checksum = store.add(image_meta['id'],
|
||||
req.body_file,
|
||||
self.options)
|
||||
# If size returned from store is different from size
|
||||
# already stored in registry, update the registry with
|
||||
# the new size of the image
|
||||
if image_meta.get('size', 0) != size:
|
||||
image_meta['size'] = size
|
||||
logger.debug("Updating image metadata for image %s"
|
||||
% image_id)
|
||||
registry.update_image_metadata(self.options,
|
||||
image_meta['id'],
|
||||
image_meta)
|
||||
|
||||
# Verify any supplied checksum value matches checksum
|
||||
# returned from store when adding image
|
||||
supplied_checksum = image_meta.get('checksum')
|
||||
if supplied_checksum and supplied_checksum != checksum:
|
||||
msg = ("Supplied checksum (%(supplied_checksum)s) and "
|
||||
"checksum generated from uploaded image "
|
||||
"(%(checksum)s) did not match. Setting image "
|
||||
"status to 'killed'.") % locals()
|
||||
self._safe_kill(req, image_meta)
|
||||
raise HTTPBadRequest(msg, content_type="text/plain",
|
||||
request=req)
|
||||
|
||||
# Update the database with the checksum returned
|
||||
# from the backend store
|
||||
logger.debug("Updating image %(image_id)s data. "
|
||||
"Checksum set to %(checksum)s, size set "
|
||||
"to %(size)d" % locals())
|
||||
m = registry.update_image_metadata(self.options, image_id,
|
||||
{'checksum': checksum,
|
||||
'size': size})
|
||||
|
||||
return location
|
||||
except exception.Duplicate, e:
|
||||
logger.error("Error adding image to store: %s", str(e))
|
||||
raise HTTPConflict(str(e), request=req)
|
||||
|
||||
def _activate(self, req, image_meta, location):
|
||||
def _activate(self, req, image_id, location):
|
||||
"""
|
||||
Sets the image status to `active` and the image's location
|
||||
attribute.
|
||||
@ -261,25 +278,25 @@ class Controller(wsgi.Controller):
|
||||
:param image_meta: Mapping of metadata about image
|
||||
:param location: Location of where Glance stored this image
|
||||
"""
|
||||
image_meta = {}
|
||||
image_meta['location'] = location
|
||||
image_meta['status'] = 'active'
|
||||
registry.update_image_metadata(self.options,
|
||||
image_meta['id'],
|
||||
return registry.update_image_metadata(self.options,
|
||||
image_id,
|
||||
image_meta)
|
||||
|
||||
def _kill(self, req, image_meta):
|
||||
def _kill(self, req, image_id):
|
||||
"""
|
||||
Marks the image status to `killed`
|
||||
|
||||
:param request: The WSGI/Webob Request object
|
||||
:param image_meta: Mapping of metadata about image
|
||||
:param image_id: Opaque image identifier
|
||||
"""
|
||||
image_meta['status'] = 'killed'
|
||||
registry.update_image_metadata(self.options,
|
||||
image_meta['id'],
|
||||
image_meta)
|
||||
image_id,
|
||||
{'status': 'killed'})
|
||||
|
||||
def _safe_kill(self, req, image_meta):
|
||||
def _safe_kill(self, req, image_id):
|
||||
"""
|
||||
Mark image killed without raising exceptions if it fails.
|
||||
|
||||
@ -287,12 +304,13 @@ class Controller(wsgi.Controller):
|
||||
not raise itself, rather it should just log its error.
|
||||
|
||||
:param request: The WSGI/Webob Request object
|
||||
:param image_id: Opaque image identifier
|
||||
"""
|
||||
try:
|
||||
self._kill(req, image_meta)
|
||||
self._kill(req, image_id)
|
||||
except Exception, e:
|
||||
logger.error("Unable to kill image %s: %s",
|
||||
image_meta['id'], repr(e))
|
||||
image_id, repr(e))
|
||||
|
||||
def _upload_and_activate(self, req, image_meta):
|
||||
"""
|
||||
@ -302,13 +320,16 @@ class Controller(wsgi.Controller):
|
||||
|
||||
:param request: The WSGI/Webob Request object
|
||||
:param image_meta: Mapping of metadata about image
|
||||
|
||||
:retval Mapping of updated image data
|
||||
"""
|
||||
try:
|
||||
image_id = image_meta['id']
|
||||
location = self._upload(req, image_meta)
|
||||
self._activate(req, image_meta, location)
|
||||
return self._activate(req, image_id, location)
|
||||
except: # unqualified b/c we're re-raising it
|
||||
exc_type, exc_value, exc_traceback = sys.exc_info()
|
||||
self._safe_kill(req, image_meta)
|
||||
self._safe_kill(req, image_id)
|
||||
# NOTE(sirp): _safe_kill uses httplib which, in turn, uses
|
||||
# Eventlet's GreenSocket. Eventlet subsequently clears exceptions
|
||||
# by calling `sys.exc_clear()`.
|
||||
@ -352,19 +373,21 @@ class Controller(wsgi.Controller):
|
||||
image data.
|
||||
"""
|
||||
image_meta = self._reserve(req)
|
||||
image_id = image_meta['id']
|
||||
|
||||
if utils.has_body(req):
|
||||
self._upload_and_activate(req, image_meta)
|
||||
image_meta = self._upload_and_activate(req, image_meta)
|
||||
else:
|
||||
if 'x-image-meta-location' in req.headers:
|
||||
location = req.headers['x-image-meta-location']
|
||||
self._activate(req, image_meta, location)
|
||||
image_meta = self._activate(req, image_id, location)
|
||||
|
||||
# APP states we should return a Location: header with the edit
|
||||
# URI of the resource newly-created.
|
||||
res = Response(request=req, body=json.dumps(dict(image=image_meta)),
|
||||
content_type="text/plain")
|
||||
res.headers.add('Location', "/images/%s" % image_meta['id'])
|
||||
status=httplib.CREATED, content_type="text/plain")
|
||||
res.headers.add('Location', "/images/%s" % image_id)
|
||||
res.headers.add('ETag', image_meta['checksum'])
|
||||
|
||||
return req.get_response(res)
|
||||
|
||||
@ -392,9 +415,14 @@ class Controller(wsgi.Controller):
|
||||
new_image_meta)
|
||||
|
||||
if has_body:
|
||||
self._upload_and_activate(req, image_meta)
|
||||
image_meta = self._upload_and_activate(req, image_meta)
|
||||
|
||||
return dict(image=image_meta)
|
||||
res = Response(request=req,
|
||||
body=json.dumps(dict(image=image_meta)),
|
||||
content_type="text/plain")
|
||||
res.headers.add('Location', "/images/%s" % id)
|
||||
res.headers.add('ETag', image_meta['checksum'])
|
||||
return res
|
||||
except exception.Invalid, e:
|
||||
msg = ("Failed to update image metadata. Got error: %(e)s"
|
||||
% locals())
|
||||
|
@ -140,40 +140,3 @@ def parse_uri_tokens(parsed_uri, example_url):
|
||||
authurl = "https://%s" % '/'.join(path_parts)
|
||||
|
||||
return user, key, authurl, container, obj
|
||||
|
||||
|
||||
def add_options(parser):
|
||||
"""
|
||||
Adds any configuration options that each store might
|
||||
have.
|
||||
|
||||
:param parser: An optparse.OptionParser object
|
||||
:retval None
|
||||
"""
|
||||
# TODO(jaypipes): Remove these imports...
|
||||
from glance.store.http import HTTPBackend
|
||||
from glance.store.s3 import S3Backend
|
||||
from glance.store.swift import SwiftBackend
|
||||
from glance.store.filesystem import FilesystemBackend
|
||||
|
||||
help_text = "The following configuration options are specific to the "\
|
||||
"Glance image store."
|
||||
|
||||
DEFAULT_STORE_CHOICES = ['file', 'swift', 's3']
|
||||
group = optparse.OptionGroup(parser, "Image Store Options", help_text)
|
||||
group.add_option('--default-store', metavar="STORE",
|
||||
default="file",
|
||||
choices=DEFAULT_STORE_CHOICES,
|
||||
help="The backend store that Glance will use to store "
|
||||
"virtual machine images to. Choices: ('%s') "
|
||||
"Default: %%default" % "','".join(DEFAULT_STORE_CHOICES))
|
||||
|
||||
backend_classes = [FilesystemBackend,
|
||||
HTTPBackend,
|
||||
SwiftBackend,
|
||||
S3Backend]
|
||||
for backend_class in backend_classes:
|
||||
if hasattr(backend_class, 'add_options'):
|
||||
backend_class.add_options(group)
|
||||
|
||||
parser.add_option_group(group)
|
||||
|
@ -19,6 +19,7 @@
|
||||
A simple filesystem-backed store
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import urlparse
|
||||
@ -110,9 +111,10 @@ class FilesystemBackend(glance.store.Backend):
|
||||
:param data: The image data to write, as a file-like object
|
||||
:param options: Conf mapping
|
||||
|
||||
:retval Tuple with (location, size)
|
||||
The location that was written, with file:// scheme prepended
|
||||
and the size in bytes of the data written
|
||||
:retval Tuple with (location, size, checksum)
|
||||
The location that was written, with file:// scheme prepended,
|
||||
the size in bytes of the data written, and the checksum of
|
||||
the image added.
|
||||
"""
|
||||
datadir = options['filesystem_store_datadir']
|
||||
|
||||
@ -127,6 +129,7 @@ class FilesystemBackend(glance.store.Backend):
|
||||
raise exception.Duplicate("Image file %s already exists!"
|
||||
% filepath)
|
||||
|
||||
checksum = hashlib.md5()
|
||||
bytes_written = 0
|
||||
with open(filepath, 'wb') as f:
|
||||
while True:
|
||||
@ -134,23 +137,11 @@ class FilesystemBackend(glance.store.Backend):
|
||||
if not buf:
|
||||
break
|
||||
bytes_written += len(buf)
|
||||
checksum.update(buf)
|
||||
f.write(buf)
|
||||
|
||||
logger.debug("Wrote %(bytes_written)d bytes to %(filepath)s"
|
||||
% locals())
|
||||
return ('file://%s' % filepath, bytes_written)
|
||||
checksum_hex = checksum.hexdigest()
|
||||
|
||||
@classmethod
|
||||
def add_options(cls, parser):
|
||||
"""
|
||||
Adds specific configuration options for this store
|
||||
|
||||
:param parser: An optparse.OptionParser object
|
||||
:retval None
|
||||
"""
|
||||
|
||||
parser.add_option('--filesystem-store-datadir', metavar="DIR",
|
||||
default="/var/lib/glance/images/",
|
||||
help="Location to write image data. This directory "
|
||||
"should be writeable by the user that runs the "
|
||||
"glance-api program. Default: %default")
|
||||
logger.debug("Wrote %(bytes_written)d bytes to %(filepath)s with "
|
||||
"checksum %(checksum_hex)s" % locals())
|
||||
return ('file://%s' % filepath, bytes_written, checksum_hex)
|
||||
|
@ -161,7 +161,7 @@ class SwiftBackend(glance.store.Backend):
|
||||
# header keys are lowercased by Swift
|
||||
if 'content-length' in resp_headers:
|
||||
size = int(resp_headers['content-length'])
|
||||
return (location, size)
|
||||
return (location, size, obj_etag)
|
||||
except ClientException, e:
|
||||
if e.http_status == httplib.CONFLICT:
|
||||
raise exception.Duplicate("Swift already has an image at "
|
||||
|
@ -220,6 +220,7 @@ def stub_out_registry_and_store_server(stubs):
|
||||
'registry_host': '0.0.0.0',
|
||||
'registry_port': '9191',
|
||||
'default_store': 'file',
|
||||
'checksum': True,
|
||||
'filesystem_store_datadir': FAKE_FILESYSTEM_ROOTDIR}
|
||||
res = self.req.get_response(server.API(options))
|
||||
|
||||
@ -277,6 +278,7 @@ def stub_out_registry_db_image_api(stubs):
|
||||
'updated_at': datetime.datetime.utcnow(),
|
||||
'deleted_at': None,
|
||||
'deleted': False,
|
||||
'checksum': None,
|
||||
'size': 13,
|
||||
'location': "swift://user:passwd@acct/container/obj.tar.0",
|
||||
'properties': [{'key': 'type',
|
||||
@ -292,6 +294,7 @@ def stub_out_registry_db_image_api(stubs):
|
||||
'updated_at': datetime.datetime.utcnow(),
|
||||
'deleted_at': None,
|
||||
'deleted': False,
|
||||
'checksum': None,
|
||||
'size': 19,
|
||||
'location': "file:///tmp/glance-tests/2",
|
||||
'properties': []}]
|
||||
@ -311,6 +314,7 @@ def stub_out_registry_db_image_api(stubs):
|
||||
glance.registry.db.api.validate_image(values)
|
||||
|
||||
values['size'] = values.get('size', 0)
|
||||
values['checksum'] = values.get('checksum')
|
||||
values['deleted'] = False
|
||||
values['properties'] = values.get('properties', {})
|
||||
values['created_at'] = datetime.datetime.utcnow()
|
||||
@ -343,6 +347,7 @@ def stub_out_registry_db_image_api(stubs):
|
||||
copy_image.update(values)
|
||||
glance.registry.db.api.validate_image(copy_image)
|
||||
props = []
|
||||
orig_properties = image['properties']
|
||||
|
||||
if 'properties' in values.keys():
|
||||
for k, v in values['properties'].items():
|
||||
@ -355,7 +360,8 @@ def stub_out_registry_db_image_api(stubs):
|
||||
p['deleted_at'] = None
|
||||
props.append(p)
|
||||
|
||||
values['properties'] = props
|
||||
orig_properties = orig_properties + props
|
||||
values['properties'] = orig_properties
|
||||
|
||||
image.update(values)
|
||||
return image
|
||||
|
@ -15,6 +15,7 @@
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
import hashlib
|
||||
import httplib
|
||||
import json
|
||||
import unittest
|
||||
@ -47,7 +48,9 @@ class TestRegistryAPI(unittest.TestCase):
|
||||
|
||||
"""
|
||||
fixture = {'id': 2,
|
||||
'name': 'fake image #2'}
|
||||
'name': 'fake image #2',
|
||||
'size': 19,
|
||||
'checksum': None}
|
||||
req = webob.Request.blank('/')
|
||||
res = req.get_response(self.api)
|
||||
res_dict = json.loads(res.body)
|
||||
@ -65,7 +68,9 @@ class TestRegistryAPI(unittest.TestCase):
|
||||
|
||||
"""
|
||||
fixture = {'id': 2,
|
||||
'name': 'fake image #2'}
|
||||
'name': 'fake image #2',
|
||||
'size': 19,
|
||||
'checksum': None}
|
||||
req = webob.Request.blank('/images')
|
||||
res = req.get_response(self.api)
|
||||
res_dict = json.loads(res.body)
|
||||
@ -85,6 +90,8 @@ class TestRegistryAPI(unittest.TestCase):
|
||||
fixture = {'id': 2,
|
||||
'name': 'fake image #2',
|
||||
'is_public': True,
|
||||
'size': 19,
|
||||
'checksum': None,
|
||||
'disk_format': 'vhd',
|
||||
'container_format': 'ovf',
|
||||
'status': 'active'}
|
||||
@ -388,7 +395,7 @@ class TestGlanceAPI(unittest.TestCase):
|
||||
for k, v in fixture_headers.iteritems():
|
||||
req.headers[k] = v
|
||||
res = req.get_response(self.api)
|
||||
self.assertEquals(res.status_int, httplib.OK)
|
||||
self.assertEquals(res.status_int, httplib.CREATED)
|
||||
|
||||
res_body = json.loads(res.body)['image']
|
||||
self.assertEquals('queued', res_body['status'])
|
||||
@ -423,7 +430,7 @@ class TestGlanceAPI(unittest.TestCase):
|
||||
req.headers['Content-Type'] = 'application/octet-stream'
|
||||
req.body = "chunk00000remainder"
|
||||
res = req.get_response(self.api)
|
||||
self.assertEquals(res.status_int, 200)
|
||||
self.assertEquals(res.status_int, httplib.CREATED)
|
||||
|
||||
res_body = json.loads(res.body)['image']
|
||||
self.assertEquals(res_body['location'],
|
||||
@ -437,6 +444,97 @@ class TestGlanceAPI(unittest.TestCase):
|
||||
"res.headerlist = %r" % res.headerlist)
|
||||
self.assertTrue('/images/3' in res.headers['location'])
|
||||
|
||||
def test_image_is_checksummed(self):
|
||||
"""Test that the image contents are checksummed properly"""
|
||||
fixture_headers = {'x-image-meta-store': 'file',
|
||||
'x-image-meta-disk-format': 'vhd',
|
||||
'x-image-meta-container-format': 'ovf',
|
||||
'x-image-meta-name': 'fake image #3'}
|
||||
image_contents = "chunk00000remainder"
|
||||
image_checksum = hashlib.md5(image_contents).hexdigest()
|
||||
|
||||
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 = image_contents
|
||||
res = req.get_response(self.api)
|
||||
self.assertEquals(res.status_int, httplib.CREATED)
|
||||
|
||||
res_body = json.loads(res.body)['image']
|
||||
self.assertEquals(res_body['location'],
|
||||
'file:///tmp/glance-tests/3')
|
||||
self.assertEquals(image_checksum, res_body['checksum'],
|
||||
"Mismatched checksum. Expected %s, got %s" %
|
||||
(image_checksum, res_body['checksum']))
|
||||
|
||||
def test_etag_equals_checksum_header(self):
|
||||
"""Test that the ETag header matches the x-image-meta-checksum"""
|
||||
fixture_headers = {'x-image-meta-store': 'file',
|
||||
'x-image-meta-disk-format': 'vhd',
|
||||
'x-image-meta-container-format': 'ovf',
|
||||
'x-image-meta-name': 'fake image #3'}
|
||||
image_contents = "chunk00000remainder"
|
||||
image_checksum = hashlib.md5(image_contents).hexdigest()
|
||||
|
||||
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 = image_contents
|
||||
res = req.get_response(self.api)
|
||||
self.assertEquals(res.status_int, httplib.CREATED)
|
||||
|
||||
# HEAD the image and check the ETag equals the checksum header...
|
||||
expected_headers = {'x-image-meta-checksum': image_checksum,
|
||||
'etag': image_checksum}
|
||||
req = webob.Request.blank("/images/3")
|
||||
req.method = 'HEAD'
|
||||
res = req.get_response(self.api)
|
||||
self.assertEquals(res.status_int, 200)
|
||||
|
||||
for key in expected_headers.keys():
|
||||
self.assertTrue(key in res.headers,
|
||||
"required header '%s' missing from "
|
||||
"returned headers" % key)
|
||||
for key, value in expected_headers.iteritems():
|
||||
self.assertEquals(value, res.headers[key])
|
||||
|
||||
def test_bad_checksum_kills_image(self):
|
||||
"""Test that the image contents are checksummed properly"""
|
||||
image_contents = "chunk00000remainder"
|
||||
bad_checksum = hashlib.md5("invalid").hexdigest()
|
||||
fixture_headers = {'x-image-meta-store': 'file',
|
||||
'x-image-meta-disk-format': 'vhd',
|
||||
'x-image-meta-container-format': 'ovf',
|
||||
'x-image-meta-name': 'fake image #3',
|
||||
'x-image-meta-checksum': bad_checksum}
|
||||
|
||||
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 = image_contents
|
||||
res = req.get_response(self.api)
|
||||
self.assertEquals(res.status_int, webob.exc.HTTPBadRequest.code)
|
||||
|
||||
# Test the image was killed...
|
||||
expected_headers = {'x-image-meta-id': '3',
|
||||
'x-image-meta-status': 'killed'}
|
||||
req = webob.Request.blank("/images/3")
|
||||
req.method = 'HEAD'
|
||||
res = req.get_response(self.api)
|
||||
self.assertEquals(res.status_int, 200)
|
||||
|
||||
for key, value in expected_headers.iteritems():
|
||||
self.assertEquals(value, res.headers[key])
|
||||
|
||||
def test_image_meta(self):
|
||||
"""Test for HEAD /images/<ID>"""
|
||||
expected_headers = {'x-image-meta-id': '2',
|
||||
|
@ -18,6 +18,7 @@
|
||||
"""Tests the filesystem backend store"""
|
||||
|
||||
import StringIO
|
||||
import hashlib
|
||||
import unittest
|
||||
import urlparse
|
||||
|
||||
@ -27,6 +28,11 @@ from glance.common import exception
|
||||
from glance.store.filesystem import FilesystemBackend, ChunkedFile
|
||||
from tests import stubs
|
||||
|
||||
FILESYSTEM_OPTIONS = {
|
||||
'verbose': True,
|
||||
'debug': True,
|
||||
'filesystem_store_datadir': stubs.FAKE_FILESYSTEM_ROOTDIR}
|
||||
|
||||
|
||||
class TestFilesystemBackend(unittest.TestCase):
|
||||
|
||||
@ -75,17 +81,17 @@ class TestFilesystemBackend(unittest.TestCase):
|
||||
expected_image_id = 42
|
||||
expected_file_size = 1024 * 5 # 5K
|
||||
expected_file_contents = "*" * expected_file_size
|
||||
expected_checksum = hashlib.md5(expected_file_contents).hexdigest()
|
||||
expected_location = "file://%s/%s" % (stubs.FAKE_FILESYSTEM_ROOTDIR,
|
||||
expected_image_id)
|
||||
image_file = StringIO.StringIO(expected_file_contents)
|
||||
options = {'verbose': True,
|
||||
'debug': True,
|
||||
'filesystem_store_datadir': stubs.FAKE_FILESYSTEM_ROOTDIR}
|
||||
|
||||
location, size = FilesystemBackend.add(42, image_file, options)
|
||||
location, size, checksum = FilesystemBackend.add(42, image_file,
|
||||
FILESYSTEM_OPTIONS)
|
||||
|
||||
self.assertEquals(expected_location, location)
|
||||
self.assertEquals(expected_file_size, size)
|
||||
self.assertEquals(expected_checksum, checksum)
|
||||
|
||||
url_pieces = urlparse.urlparse("file:///tmp/glance-tests/42")
|
||||
new_image_file = FilesystemBackend.get(url_pieces)
|
||||
@ -110,7 +116,7 @@ class TestFilesystemBackend(unittest.TestCase):
|
||||
'filesystem_store_datadir': stubs.FAKE_FILESYSTEM_ROOTDIR}
|
||||
self.assertRaises(exception.Duplicate,
|
||||
FilesystemBackend.add,
|
||||
2, image_file, options)
|
||||
2, image_file, FILESYSTEM_OPTIONS)
|
||||
|
||||
def test_delete(self):
|
||||
"""
|
||||
|
@ -68,15 +68,20 @@ def stub_out_swift_common_client(stubs):
|
||||
if hasattr(contents, 'read'):
|
||||
fixture_object = StringIO.StringIO()
|
||||
chunk = contents.read(SwiftBackend.CHUNKSIZE)
|
||||
checksum = hashlib.md5()
|
||||
while chunk:
|
||||
fixture_object.write(chunk)
|
||||
checksum.update(chunk)
|
||||
chunk = contents.read(SwiftBackend.CHUNKSIZE)
|
||||
etag = checksum.hexdigest()
|
||||
else:
|
||||
fixture_object = StringIO.StringIO(contents)
|
||||
etag = hashlib.md5(fixture_object.getvalue()).hexdigest()
|
||||
read_len = fixture_object.len
|
||||
fixture_objects[fixture_key] = fixture_object
|
||||
fixture_headers[fixture_key] = {
|
||||
'content-length': fixture_object.len,
|
||||
'etag': hashlib.md5(fixture_object.read()).hexdigest()}
|
||||
'content-length': read_len,
|
||||
'etag': etag}
|
||||
return fixture_headers[fixture_key]['etag']
|
||||
else:
|
||||
msg = ("Object PUT failed - Object with key %s already exists"
|
||||
@ -189,8 +194,9 @@ class TestSwiftBackend(unittest.TestCase):
|
||||
def test_add(self):
|
||||
"""Test that we can add an image via the swift backend"""
|
||||
expected_image_id = 42
|
||||
expected_swift_size = 1024 * 5 # 5K
|
||||
expected_swift_size = FIVE_KB
|
||||
expected_swift_contents = "*" * expected_swift_size
|
||||
expected_checksum = hashlib.md5(expected_swift_contents).hexdigest()
|
||||
expected_location = format_swift_location(
|
||||
SWIFT_OPTIONS['swift_store_user'],
|
||||
SWIFT_OPTIONS['swift_store_key'],
|
||||
@ -199,10 +205,12 @@ class TestSwiftBackend(unittest.TestCase):
|
||||
expected_image_id)
|
||||
image_swift = StringIO.StringIO(expected_swift_contents)
|
||||
|
||||
location, size = SwiftBackend.add(42, image_swift, SWIFT_OPTIONS)
|
||||
location, size, checksum = SwiftBackend.add(42, image_swift,
|
||||
SWIFT_OPTIONS)
|
||||
|
||||
self.assertEquals(expected_location, location)
|
||||
self.assertEquals(expected_swift_size, size)
|
||||
self.assertEquals(expected_checksum, checksum)
|
||||
|
||||
url_pieces = urlparse.urlparse(expected_location)
|
||||
new_image_swift = SwiftBackend.get(url_pieces)
|
||||
@ -243,8 +251,9 @@ class TestSwiftBackend(unittest.TestCase):
|
||||
options['swift_store_create_container_on_put'] = 'True'
|
||||
options['swift_store_container'] = 'noexist'
|
||||
expected_image_id = 42
|
||||
expected_swift_size = 1024 * 5 # 5K
|
||||
expected_swift_size = FIVE_KB
|
||||
expected_swift_contents = "*" * expected_swift_size
|
||||
expected_checksum = hashlib.md5(expected_swift_contents).hexdigest()
|
||||
expected_location = format_swift_location(
|
||||
options['swift_store_user'],
|
||||
options['swift_store_key'],
|
||||
@ -253,10 +262,12 @@ class TestSwiftBackend(unittest.TestCase):
|
||||
expected_image_id)
|
||||
image_swift = StringIO.StringIO(expected_swift_contents)
|
||||
|
||||
location, size = SwiftBackend.add(42, image_swift, options)
|
||||
location, size, checksum = SwiftBackend.add(42, image_swift,
|
||||
options)
|
||||
|
||||
self.assertEquals(expected_location, location)
|
||||
self.assertEquals(expected_swift_size, size)
|
||||
self.assertEquals(expected_checksum, checksum)
|
||||
|
||||
url_pieces = urlparse.urlparse(expected_location)
|
||||
new_image_swift = SwiftBackend.get(url_pieces)
|
||||
|
Loading…
Reference in New Issue
Block a user