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:
jaypipes@gmail.com 2011-03-08 10:22:44 -05:00
parent a3690f0c9a
commit af11621170
13 changed files with 256 additions and 122 deletions

View File

@ -67,6 +67,7 @@ JSON-encoded mapping in the following format::
'disk_format': 'vhd', 'disk_format': 'vhd',
'container_format': 'ovf', 'container_format': 'ovf',
'size': '5368709120', 'size': '5368709120',
'checksum': 'c2e5db72bd7fd153f53ede5da5a06de3',
'location': 'swift://account:key/container/image.tar.gz.0', 'location': 'swift://account:key/container/image.tar.gz.0',
'created_at': '2010-02-03 09:34:01', 'created_at': '2010-02-03 09:34:01',
'updated_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-disk-format vhd
x-image-meta-container-format ovf x-image-meta-container-format ovf
x-image-meta-size 5368709120 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-location swift://account:key/container/image.tar.gz.0
x-image-meta-created_at 2010-02-03 09:34:01 x-image-meta-created_at 2010-02-03 09:34:01
x-image-meta-updated_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 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 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 Retrieving a Virtual Machine Image
---------------------------------- ----------------------------------
@ -166,6 +171,7 @@ returned from the above ``GET`` request::
x-image-meta-disk-format vhd x-image-meta-disk-format vhd
x-image-meta-container-format ovf x-image-meta-container-format ovf
x-image-meta-size 5368709120 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-location swift://account:key/container/image.tar.gz.0
x-image-meta-created_at 2010-02-03 09:34:01 x-image-meta-created_at 2010-02-03 09:34:01
x-image-meta-updated_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 response's `Content-Length` header shall be equal to the value of
the `x-image-meta-size` header 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 The image data itself will be the body of the HTTP response returned
from the request, which will have content-type of from the request, which will have content-type of
`application/octet-stream`. `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 When not present, Glance will calculate the image's size based on the size
of the request body. 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`` * ``x-image-meta-is-public``
This header is optional. This header is optional.

View File

@ -142,7 +142,10 @@ class BaseClient(object):
c.request(method, action, body, headers) c.request(method, action, body, headers)
res = c.getresponse() res = c.getresponse()
status_code = self.get_status_code(res) 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 return res
elif status_code == httplib.UNAUTHORIZED: elif status_code == httplib.UNAUTHORIZED:
raise exception.NotAuthorized raise exception.NotAuthorized

View File

@ -42,7 +42,7 @@ BASE_MODEL_ATTRS = set(['id', 'created_at', 'updated_at', 'deleted_at',
IMAGE_ATTRS = BASE_MODEL_ATTRS | set(['name', 'status', 'size', IMAGE_ATTRS = BASE_MODEL_ATTRS | set(['name', 'status', 'size',
'disk_format', 'container_format', 'disk_format', 'container_format',
'is_public', 'location']) 'is_public', 'location', 'checksum'])
CONTAINER_FORMATS = ['ami', 'ari', 'aki', 'bare', 'ovf'] CONTAINER_FORMATS = ['ami', 'ari', 'aki', 'bare', 'ovf']
DISK_FORMATS = ['ami', 'ari', 'aki', 'vhd', 'vmdk', 'raw', 'qcow2', 'vdi'] DISK_FORMATS = ['ami', 'ari', 'aki', 'vhd', 'vmdk', 'raw', 'qcow2', 'vdi']

View File

@ -104,6 +104,7 @@ class Image(BASE, ModelBase):
status = Column(String(30), nullable=False) status = Column(String(30), nullable=False)
is_public = Column(Boolean, nullable=False, default=False) is_public = Column(Boolean, nullable=False, default=False)
location = Column(Text) location = Column(Text)
checksum = Column(String(32))
class ImageProperty(BASE, ModelBase): class ImageProperty(BASE, ModelBase):

View File

@ -31,6 +31,8 @@ from glance.registry.db import api as db_api
logger = logging.getLogger('glance.registry.server') logger = logging.getLogger('glance.registry.server')
DISPLAY_FIELDS_IN_INDEX = ['id', 'name', 'size', 'checksum']
class Controller(wsgi.Controller): class Controller(wsgi.Controller):
"""Controller for the reference implementation registry server""" """Controller for the reference implementation registry server"""
@ -49,14 +51,22 @@ class Controller(wsgi.Controller):
Where image_list is a sequence of mappings:: 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) images = db_api.image_get_all_public(None)
image_dicts = [dict(id=i['id'], results = []
name=i['name'], for image in images:
size=i['size']) for i in images] result = {}
return dict(images=image_dicts) for field in DISPLAY_FIELDS_IN_INDEX:
result[field] = image[field]
results.append(result)
return dict(images=results)
def detail(self, req): def detail(self, req):
"""Return detailed information for all public, non-deleted images """Return detailed information for all public, non-deleted images

View File

@ -29,6 +29,7 @@ Configuration Options
""" """
import httplib
import json import json
import logging import logging
import sys import sys
@ -68,8 +69,8 @@ class Controller(wsgi.Controller):
GET /images/<ID> -- Return image data for image with id <ID> GET /images/<ID> -- Return image data for image with id <ID>
POST /images -- Store image data and return metadata about the POST /images -- Store image data and return metadata about the
newly-stored image newly-stored image
PUT /images/<ID> -- Update image metadata (not image data, since PUT /images/<ID> -- Update image metadata and/or upload image
image data is immutable once stored) data for a previously-reserved image
DELETE /images/<ID> -- Delete the image with id <ID> DELETE /images/<ID> -- Delete the image with id <ID>
""" """
@ -90,7 +91,9 @@ class Controller(wsgi.Controller):
{'images': [ {'images': [
{'id': <ID>, {'id': <ID>,
'name': <NAME>, 'name': <NAME>,
'size': <SIZE>}, ... 'size': <SIZE>,
'checksum': <CHECKSUM>
}, ...
]} ]}
""" """
images = registry.get_images_list(self.options) images = registry.get_images_list(self.options)
@ -109,6 +112,7 @@ class Controller(wsgi.Controller):
'size': <SIZE>, 'size': <SIZE>,
'disk_format': <DISK_FORMAT>, 'disk_format': <DISK_FORMAT>,
'container_format': <CONTAINER_FORMAT>, 'container_format': <CONTAINER_FORMAT>,
'checksum': <CHECKSUM>,
'store': <STORE>, 'store': <STORE>,
'status': <STATUS>, 'status': <STATUS>,
'created_at': <TIMESTAMP>, 'created_at': <TIMESTAMP>,
@ -134,6 +138,8 @@ class Controller(wsgi.Controller):
res = Response(request=req) res = Response(request=req)
utils.inject_image_meta_into_headers(res, image) 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) return req.get_response(res)
@ -163,6 +169,8 @@ class Controller(wsgi.Controller):
res = Response(app_iter=image_iterator(), res = Response(app_iter=image_iterator(),
content_type="text/plain") content_type="text/plain")
utils.inject_image_meta_into_headers(res, image) 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) return req.get_response(res)
def _reserve(self, req): def _reserve(self, req):
@ -223,36 +231,45 @@ class Controller(wsgi.Controller):
store = self.get_store_or_400(req, store_name) store = self.get_store_or_400(req, store_name)
image_meta['status'] = 'saving'
image_id = image_meta['id'] image_id = image_meta['id']
logger.debug("Updating image metadata for image %s" logger.debug("Setting image %s to status 'saving'"
% image_id) % image_id)
registry.update_image_metadata(self.options, registry.update_image_metadata(self.options, image_id,
image_meta['id'], {'status': 'saving'})
image_meta)
try: try:
logger.debug("Uploading image data for image %(image_id)s " logger.debug("Uploading image data for image %(image_id)s "
"to %(store_name)s store" % locals()) "to %(store_name)s store" % locals())
location, size = store.add(image_meta['id'], location, size, checksum = store.add(image_meta['id'],
req.body_file, req.body_file,
self.options) self.options)
# If size returned from store is different from size
# already stored in registry, update the registry with # Verify any supplied checksum value matches checksum
# the new size of the image # returned from store when adding image
if image_meta.get('size', 0) != size: supplied_checksum = image_meta.get('checksum')
image_meta['size'] = size if supplied_checksum and supplied_checksum != checksum:
logger.debug("Updating image metadata for image %s" msg = ("Supplied checksum (%(supplied_checksum)s) and "
% image_id) "checksum generated from uploaded image "
registry.update_image_metadata(self.options, "(%(checksum)s) did not match. Setting image "
image_meta['id'], "status to 'killed'.") % locals()
image_meta) 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 return location
except exception.Duplicate, e: except exception.Duplicate, e:
logger.error("Error adding image to store: %s", str(e)) logger.error("Error adding image to store: %s", str(e))
raise HTTPConflict(str(e), request=req) 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 Sets the image status to `active` and the image's location
attribute. attribute.
@ -261,25 +278,25 @@ class Controller(wsgi.Controller):
:param image_meta: Mapping of metadata about image :param image_meta: Mapping of metadata about image
:param location: Location of where Glance stored this image :param location: Location of where Glance stored this image
""" """
image_meta = {}
image_meta['location'] = location image_meta['location'] = location
image_meta['status'] = 'active' image_meta['status'] = 'active'
registry.update_image_metadata(self.options, return registry.update_image_metadata(self.options,
image_meta['id'], image_id,
image_meta) image_meta)
def _kill(self, req, image_meta): def _kill(self, req, image_id):
""" """
Marks the image status to `killed` Marks the image status to `killed`
:param request: The WSGI/Webob Request object :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, registry.update_image_metadata(self.options,
image_meta['id'], image_id,
image_meta) {'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. 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. not raise itself, rather it should just log its error.
:param request: The WSGI/Webob Request object :param request: The WSGI/Webob Request object
:param image_id: Opaque image identifier
""" """
try: try:
self._kill(req, image_meta) self._kill(req, image_id)
except Exception, e: except Exception, e:
logger.error("Unable to kill image %s: %s", 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): def _upload_and_activate(self, req, image_meta):
""" """
@ -302,13 +320,16 @@ class Controller(wsgi.Controller):
:param request: The WSGI/Webob Request object :param request: The WSGI/Webob Request object
:param image_meta: Mapping of metadata about image :param image_meta: Mapping of metadata about image
:retval Mapping of updated image data
""" """
try: try:
image_id = image_meta['id']
location = self._upload(req, image_meta) 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 except: # unqualified b/c we're re-raising it
exc_type, exc_value, exc_traceback = sys.exc_info() 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 # NOTE(sirp): _safe_kill uses httplib which, in turn, uses
# Eventlet's GreenSocket. Eventlet subsequently clears exceptions # Eventlet's GreenSocket. Eventlet subsequently clears exceptions
# by calling `sys.exc_clear()`. # by calling `sys.exc_clear()`.
@ -352,19 +373,21 @@ class Controller(wsgi.Controller):
image data. image data.
""" """
image_meta = self._reserve(req) image_meta = self._reserve(req)
image_id = image_meta['id']
if utils.has_body(req): if utils.has_body(req):
self._upload_and_activate(req, image_meta) image_meta = self._upload_and_activate(req, image_meta)
else: else:
if 'x-image-meta-location' in req.headers: if 'x-image-meta-location' in req.headers:
location = req.headers['x-image-meta-location'] 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 # APP states we should return a Location: header with the edit
# URI of the resource newly-created. # URI of the resource newly-created.
res = Response(request=req, body=json.dumps(dict(image=image_meta)), res = Response(request=req, body=json.dumps(dict(image=image_meta)),
content_type="text/plain") status=httplib.CREATED, content_type="text/plain")
res.headers.add('Location', "/images/%s" % image_meta['id']) res.headers.add('Location', "/images/%s" % image_id)
res.headers.add('ETag', image_meta['checksum'])
return req.get_response(res) return req.get_response(res)
@ -392,9 +415,14 @@ class Controller(wsgi.Controller):
new_image_meta) new_image_meta)
if has_body: 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: except exception.Invalid, e:
msg = ("Failed to update image metadata. Got error: %(e)s" msg = ("Failed to update image metadata. Got error: %(e)s"
% locals()) % locals())

View File

@ -140,40 +140,3 @@ def parse_uri_tokens(parsed_uri, example_url):
authurl = "https://%s" % '/'.join(path_parts) authurl = "https://%s" % '/'.join(path_parts)
return user, key, authurl, container, obj 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)

View File

@ -19,6 +19,7 @@
A simple filesystem-backed store A simple filesystem-backed store
""" """
import hashlib
import logging import logging
import os import os
import urlparse import urlparse
@ -110,9 +111,10 @@ class FilesystemBackend(glance.store.Backend):
:param data: The image data to write, as a file-like object :param data: The image data to write, as a file-like object
:param options: Conf mapping :param options: Conf mapping
:retval Tuple with (location, size) :retval Tuple with (location, size, checksum)
The location that was written, with file:// scheme prepended The location that was written, with file:// scheme prepended,
and the size in bytes of the data written the size in bytes of the data written, and the checksum of
the image added.
""" """
datadir = options['filesystem_store_datadir'] datadir = options['filesystem_store_datadir']
@ -127,6 +129,7 @@ class FilesystemBackend(glance.store.Backend):
raise exception.Duplicate("Image file %s already exists!" raise exception.Duplicate("Image file %s already exists!"
% filepath) % filepath)
checksum = hashlib.md5()
bytes_written = 0 bytes_written = 0
with open(filepath, 'wb') as f: with open(filepath, 'wb') as f:
while True: while True:
@ -134,23 +137,11 @@ class FilesystemBackend(glance.store.Backend):
if not buf: if not buf:
break break
bytes_written += len(buf) bytes_written += len(buf)
checksum.update(buf)
f.write(buf) f.write(buf)
logger.debug("Wrote %(bytes_written)d bytes to %(filepath)s" checksum_hex = checksum.hexdigest()
% locals())
return ('file://%s' % filepath, bytes_written)
@classmethod logger.debug("Wrote %(bytes_written)d bytes to %(filepath)s with "
def add_options(cls, parser): "checksum %(checksum_hex)s" % locals())
""" return ('file://%s' % filepath, bytes_written, checksum_hex)
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")

View File

@ -161,7 +161,7 @@ class SwiftBackend(glance.store.Backend):
# header keys are lowercased by Swift # header keys are lowercased by Swift
if 'content-length' in resp_headers: if 'content-length' in resp_headers:
size = int(resp_headers['content-length']) size = int(resp_headers['content-length'])
return (location, size) return (location, size, obj_etag)
except ClientException, e: except ClientException, e:
if e.http_status == httplib.CONFLICT: if e.http_status == httplib.CONFLICT:
raise exception.Duplicate("Swift already has an image at " raise exception.Duplicate("Swift already has an image at "

View File

@ -220,6 +220,7 @@ def stub_out_registry_and_store_server(stubs):
'registry_host': '0.0.0.0', 'registry_host': '0.0.0.0',
'registry_port': '9191', 'registry_port': '9191',
'default_store': 'file', 'default_store': 'file',
'checksum': True,
'filesystem_store_datadir': FAKE_FILESYSTEM_ROOTDIR} 'filesystem_store_datadir': FAKE_FILESYSTEM_ROOTDIR}
res = self.req.get_response(server.API(options)) 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(), 'updated_at': datetime.datetime.utcnow(),
'deleted_at': None, 'deleted_at': None,
'deleted': False, 'deleted': False,
'checksum': None,
'size': 13, 'size': 13,
'location': "swift://user:passwd@acct/container/obj.tar.0", 'location': "swift://user:passwd@acct/container/obj.tar.0",
'properties': [{'key': 'type', 'properties': [{'key': 'type',
@ -292,6 +294,7 @@ def stub_out_registry_db_image_api(stubs):
'updated_at': datetime.datetime.utcnow(), 'updated_at': datetime.datetime.utcnow(),
'deleted_at': None, 'deleted_at': None,
'deleted': False, 'deleted': False,
'checksum': None,
'size': 19, 'size': 19,
'location': "file:///tmp/glance-tests/2", 'location': "file:///tmp/glance-tests/2",
'properties': []}] 'properties': []}]
@ -311,6 +314,7 @@ def stub_out_registry_db_image_api(stubs):
glance.registry.db.api.validate_image(values) glance.registry.db.api.validate_image(values)
values['size'] = values.get('size', 0) values['size'] = values.get('size', 0)
values['checksum'] = values.get('checksum')
values['deleted'] = False values['deleted'] = False
values['properties'] = values.get('properties', {}) values['properties'] = values.get('properties', {})
values['created_at'] = datetime.datetime.utcnow() values['created_at'] = datetime.datetime.utcnow()
@ -343,6 +347,7 @@ def stub_out_registry_db_image_api(stubs):
copy_image.update(values) copy_image.update(values)
glance.registry.db.api.validate_image(copy_image) glance.registry.db.api.validate_image(copy_image)
props = [] props = []
orig_properties = image['properties']
if 'properties' in values.keys(): if 'properties' in values.keys():
for k, v in values['properties'].items(): for k, v in values['properties'].items():
@ -355,7 +360,8 @@ def stub_out_registry_db_image_api(stubs):
p['deleted_at'] = None p['deleted_at'] = None
props.append(p) props.append(p)
values['properties'] = props orig_properties = orig_properties + props
values['properties'] = orig_properties
image.update(values) image.update(values)
return image return image

View File

@ -15,6 +15,7 @@
# License for the specific language governing permissions and limitations # License for the specific language governing permissions and limitations
# under the License. # under the License.
import hashlib
import httplib import httplib
import json import json
import unittest import unittest
@ -47,7 +48,9 @@ class TestRegistryAPI(unittest.TestCase):
""" """
fixture = {'id': 2, fixture = {'id': 2,
'name': 'fake image #2'} 'name': 'fake image #2',
'size': 19,
'checksum': None}
req = webob.Request.blank('/') req = webob.Request.blank('/')
res = req.get_response(self.api) res = req.get_response(self.api)
res_dict = json.loads(res.body) res_dict = json.loads(res.body)
@ -65,7 +68,9 @@ class TestRegistryAPI(unittest.TestCase):
""" """
fixture = {'id': 2, fixture = {'id': 2,
'name': 'fake image #2'} 'name': 'fake image #2',
'size': 19,
'checksum': None}
req = webob.Request.blank('/images') req = webob.Request.blank('/images')
res = req.get_response(self.api) res = req.get_response(self.api)
res_dict = json.loads(res.body) res_dict = json.loads(res.body)
@ -85,6 +90,8 @@ class TestRegistryAPI(unittest.TestCase):
fixture = {'id': 2, fixture = {'id': 2,
'name': 'fake image #2', 'name': 'fake image #2',
'is_public': True, 'is_public': True,
'size': 19,
'checksum': None,
'disk_format': 'vhd', 'disk_format': 'vhd',
'container_format': 'ovf', 'container_format': 'ovf',
'status': 'active'} 'status': 'active'}
@ -388,7 +395,7 @@ class TestGlanceAPI(unittest.TestCase):
for k, v in fixture_headers.iteritems(): for k, v in fixture_headers.iteritems():
req.headers[k] = v req.headers[k] = v
res = req.get_response(self.api) 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'] res_body = json.loads(res.body)['image']
self.assertEquals('queued', res_body['status']) self.assertEquals('queued', res_body['status'])
@ -423,7 +430,7 @@ class TestGlanceAPI(unittest.TestCase):
req.headers['Content-Type'] = 'application/octet-stream' req.headers['Content-Type'] = 'application/octet-stream'
req.body = "chunk00000remainder" req.body = "chunk00000remainder"
res = req.get_response(self.api) 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'] res_body = json.loads(res.body)['image']
self.assertEquals(res_body['location'], self.assertEquals(res_body['location'],
@ -437,6 +444,97 @@ class TestGlanceAPI(unittest.TestCase):
"res.headerlist = %r" % res.headerlist) "res.headerlist = %r" % res.headerlist)
self.assertTrue('/images/3' in res.headers['location']) 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): def test_image_meta(self):
"""Test for HEAD /images/<ID>""" """Test for HEAD /images/<ID>"""
expected_headers = {'x-image-meta-id': '2', expected_headers = {'x-image-meta-id': '2',

View File

@ -18,6 +18,7 @@
"""Tests the filesystem backend store""" """Tests the filesystem backend store"""
import StringIO import StringIO
import hashlib
import unittest import unittest
import urlparse import urlparse
@ -27,6 +28,11 @@ from glance.common import exception
from glance.store.filesystem import FilesystemBackend, ChunkedFile from glance.store.filesystem import FilesystemBackend, ChunkedFile
from tests import stubs from tests import stubs
FILESYSTEM_OPTIONS = {
'verbose': True,
'debug': True,
'filesystem_store_datadir': stubs.FAKE_FILESYSTEM_ROOTDIR}
class TestFilesystemBackend(unittest.TestCase): class TestFilesystemBackend(unittest.TestCase):
@ -75,17 +81,17 @@ class TestFilesystemBackend(unittest.TestCase):
expected_image_id = 42 expected_image_id = 42
expected_file_size = 1024 * 5 # 5K expected_file_size = 1024 * 5 # 5K
expected_file_contents = "*" * expected_file_size expected_file_contents = "*" * expected_file_size
expected_checksum = hashlib.md5(expected_file_contents).hexdigest()
expected_location = "file://%s/%s" % (stubs.FAKE_FILESYSTEM_ROOTDIR, expected_location = "file://%s/%s" % (stubs.FAKE_FILESYSTEM_ROOTDIR,
expected_image_id) expected_image_id)
image_file = StringIO.StringIO(expected_file_contents) 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_location, location)
self.assertEquals(expected_file_size, size) self.assertEquals(expected_file_size, size)
self.assertEquals(expected_checksum, checksum)
url_pieces = urlparse.urlparse("file:///tmp/glance-tests/42") url_pieces = urlparse.urlparse("file:///tmp/glance-tests/42")
new_image_file = FilesystemBackend.get(url_pieces) new_image_file = FilesystemBackend.get(url_pieces)
@ -110,7 +116,7 @@ class TestFilesystemBackend(unittest.TestCase):
'filesystem_store_datadir': stubs.FAKE_FILESYSTEM_ROOTDIR} 'filesystem_store_datadir': stubs.FAKE_FILESYSTEM_ROOTDIR}
self.assertRaises(exception.Duplicate, self.assertRaises(exception.Duplicate,
FilesystemBackend.add, FilesystemBackend.add,
2, image_file, options) 2, image_file, FILESYSTEM_OPTIONS)
def test_delete(self): def test_delete(self):
""" """

View File

@ -68,15 +68,20 @@ def stub_out_swift_common_client(stubs):
if hasattr(contents, 'read'): if hasattr(contents, 'read'):
fixture_object = StringIO.StringIO() fixture_object = StringIO.StringIO()
chunk = contents.read(SwiftBackend.CHUNKSIZE) chunk = contents.read(SwiftBackend.CHUNKSIZE)
checksum = hashlib.md5()
while chunk: while chunk:
fixture_object.write(chunk) fixture_object.write(chunk)
checksum.update(chunk)
chunk = contents.read(SwiftBackend.CHUNKSIZE) chunk = contents.read(SwiftBackend.CHUNKSIZE)
etag = checksum.hexdigest()
else: else:
fixture_object = StringIO.StringIO(contents) fixture_object = StringIO.StringIO(contents)
etag = hashlib.md5(fixture_object.getvalue()).hexdigest()
read_len = fixture_object.len
fixture_objects[fixture_key] = fixture_object fixture_objects[fixture_key] = fixture_object
fixture_headers[fixture_key] = { fixture_headers[fixture_key] = {
'content-length': fixture_object.len, 'content-length': read_len,
'etag': hashlib.md5(fixture_object.read()).hexdigest()} 'etag': etag}
return fixture_headers[fixture_key]['etag'] return fixture_headers[fixture_key]['etag']
else: else:
msg = ("Object PUT failed - Object with key %s already exists" msg = ("Object PUT failed - Object with key %s already exists"
@ -189,8 +194,9 @@ class TestSwiftBackend(unittest.TestCase):
def test_add(self): def test_add(self):
"""Test that we can add an image via the swift backend""" """Test that we can add an image via the swift backend"""
expected_image_id = 42 expected_image_id = 42
expected_swift_size = 1024 * 5 # 5K expected_swift_size = FIVE_KB
expected_swift_contents = "*" * expected_swift_size expected_swift_contents = "*" * expected_swift_size
expected_checksum = hashlib.md5(expected_swift_contents).hexdigest()
expected_location = format_swift_location( expected_location = format_swift_location(
SWIFT_OPTIONS['swift_store_user'], SWIFT_OPTIONS['swift_store_user'],
SWIFT_OPTIONS['swift_store_key'], SWIFT_OPTIONS['swift_store_key'],
@ -199,10 +205,12 @@ class TestSwiftBackend(unittest.TestCase):
expected_image_id) expected_image_id)
image_swift = StringIO.StringIO(expected_swift_contents) 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_location, location)
self.assertEquals(expected_swift_size, size) self.assertEquals(expected_swift_size, size)
self.assertEquals(expected_checksum, checksum)
url_pieces = urlparse.urlparse(expected_location) url_pieces = urlparse.urlparse(expected_location)
new_image_swift = SwiftBackend.get(url_pieces) 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_create_container_on_put'] = 'True'
options['swift_store_container'] = 'noexist' options['swift_store_container'] = 'noexist'
expected_image_id = 42 expected_image_id = 42
expected_swift_size = 1024 * 5 # 5K expected_swift_size = FIVE_KB
expected_swift_contents = "*" * expected_swift_size expected_swift_contents = "*" * expected_swift_size
expected_checksum = hashlib.md5(expected_swift_contents).hexdigest()
expected_location = format_swift_location( expected_location = format_swift_location(
options['swift_store_user'], options['swift_store_user'],
options['swift_store_key'], options['swift_store_key'],
@ -253,10 +262,12 @@ class TestSwiftBackend(unittest.TestCase):
expected_image_id) expected_image_id)
image_swift = StringIO.StringIO(expected_swift_contents) 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_location, location)
self.assertEquals(expected_swift_size, size) self.assertEquals(expected_swift_size, size)
self.assertEquals(expected_checksum, checksum)
url_pieces = urlparse.urlparse(expected_location) url_pieces = urlparse.urlparse(expected_location)
new_image_swift = SwiftBackend.get(url_pieces) new_image_swift = SwiftBackend.get(url_pieces)