Merge lp:~jaypipes/glance/checksum into lp:~glance-coresec/glance/cactus-trunk

Proposed by Jay Pipes
Status: Merged
Approved by: Jay Pipes
Approved revision: 88
Merged at revision: 97
Proposed branch: lp:~jaypipes/glance/checksum
Merge into: lp:~glance-coresec/glance/cactus-trunk
Prerequisite: lp:~jaypipes/glance/bug730213
Diff against target: 1005 lines (+351/-129)
16 files modified
doc/source/glanceapi.rst (+20/-0)
glance/client.py (+4/-1)
glance/registry/db/api.py (+1/-1)
glance/registry/db/migrate_repo/versions/004_add_checksum.py (+80/-0)
glance/registry/db/models.py (+1/-0)
glance/registry/server.py (+21/-8)
glance/server.py (+70/-40)
glance/store/__init__.py (+0/-37)
glance/store/filesystem.py (+12/-21)
glance/store/swift.py (+1/-1)
tests/functional/__init__.py (+2/-2)
tests/functional/test_curl_api.py (+2/-1)
tests/stubs.py (+6/-1)
tests/unit/test_api.py (+103/-5)
tests/unit/test_filesystem_store.py (+11/-5)
tests/unit/test_swift_store.py (+17/-6)
To merge this branch: bzr merge lp:~jaypipes/glance/checksum
Reviewer Review Type Date Requested Status
Cory Wright (community) Approve
Jay Pipes (community) Approve
Rick Harris (community) Approve
Thierry Carrez (community) ffe Approve
Sandy Walsh (community) Approve
Chuck Thier Pending
Review via email: mp+54537@code.launchpad.net

This proposal supersedes a proposal from 2011-03-08.

Description of the change

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.

Includes migration script.

NOTE: This does not include the DB migration script. Separate bug will be filed for that.

To post a comment you must log in.
Revision history for this message
Chuck Thier (cthier) wrote : Posted in a previous version of this proposal

The swift parts look reasonable to me. The only other thing that I might add is to clarify in the docs that the checksum is an md5 checksum, otherwise people may try to use CRC.

review: Approve
Revision history for this message
Jay Pipes (jaypipes) wrote : Posted in a previous version of this proposal

thx Chuck. Made a note in the docs about the checksum being an MD5 checksum.

Revision history for this message
Rick Harris (rconradharris) wrote : Posted in a previous version of this proposal

Nice patch.

Unfortunately with the state of Nova <=> Glance trunks at the moment, I wasn't able to test functionally. That said, this seems pretty safe to go ahead and merge, and if we have issues, we can fix them along with all the other fixes that are in progress at the moment :)

> 110 + checksum = Column(String(32))

I assume the answer is YAGNI, but I'll ask it anyway: Will we ever allow SHA1 or SHA256?

Would it make sense to make this a String(64) or, heck, a String(255) for flexibility?

review: Approve
Revision history for this message
Jay Pipes (jaypipes) wrote : Posted in a previous version of this proposal

> > 110 + checksum = Column(String(32))
>
> I assume the answer is YAGNI, but I'll ask it anyway: Will we ever allow SHA1
> or SHA256?
>
> Would it make sense to make this a String(64) or, heck, a String(255) for
> flexibility?

Hmm, good question. Not sure, really. I suppose we could expand it in the future if we decide its a needed feature.

Revision history for this message
Cory Wright (corywright) wrote : Posted in a previous version of this proposal

I noticed that there is no migration script to add the checksum column. Trunk is already missing a migration for disk_format/container_format, but we probably shouldn't get in the habit of merging to trunk without migrations, since that effectively leaves trunk broken. However, the tests seem to run fine without the migration.

I tried merging trunk into this branch to test these changes with the glance cli tool, but there were conflicts so you may want to merge trunk once more.

Otherwise, this looks good to me. I'm going to mark it as Needs Fixing because of the missing migration. If you think it's ok to merge this to trunk without the migration then let me know and I'll mark this approved.

review: Needs Fixing
Revision history for this message
Jay Pipes (jaypipes) wrote : Posted in a previous version of this proposal

> I noticed that there is no migration script to add the checksum column. Trunk
> is already missing a migration for disk_format/container_format, but we
> probably shouldn't get in the habit of merging to trunk without migrations,
> since that effectively leaves trunk broken. However, the tests seem to run
> fine without the migration.
>
> I tried merging trunk into this branch to test these changes with the glance
> cli tool, but there were conflicts so you may want to merge trunk once more.
>
> Otherwise, this looks good to me. I'm going to mark it as Needs Fixing
> because of the missing migration. If you think it's ok to merge this to trunk
> without the migration then let me know and I'll mark this approved.

Agreed, though I did note this in the merge proposal commit message:

"NOTE: This does not include the DB migration script. Separate bug will be filed for that."

But, I'll work on a migrate script after merging trunk again. Thanks, Cory!

Revision history for this message
Cory Wright (corywright) wrote : Posted in a previous version of this proposal

You sure did mention it, sorry about that. I read the commit message yesterday when I first looked at this and forgot.

Revision history for this message
Sandy Walsh (sandy-walsh) wrote : Posted in a previous version of this proposal

Hmm, I got bitten by this migration bug yesterday ... can't this be put in this branch to keep trunk happy?

Revision history for this message
Jay Pipes (jaypipes) wrote : Posted in a previous version of this proposal

> Hmm, I got bitten by this migration bug yesterday ... can't this be put in
> this branch to keep trunk happy?

? This hasn't been merged yet, so I'm unclear how this could have bitten you :)

FYI, I've now figured out the migrations stuff. Full steam ahead today on getting the image format migration bug done and getting this branch merged with a migration file.

Revision history for this message
Sandy Walsh (sandy-walsh) wrote : Posted in a previous version of this proposal

sorry, it was a nova issue. _get_kernel_ramdisk_from_image checks 'disk_format' != 'ami'

I guess that just stresses the importance of having the migrations in place with the branch :)

Minor tweak ... ping me and I'll approve immediately after

278 missing _() ?

review: Needs Fixing
Revision history for this message
Jay Pipes (jaypipes) wrote : Posted in a previous version of this proposal

On Wed, Mar 23, 2011 at 10:26 AM, Sandy Walsh <email address hidden> wrote:
> Review: Needs Fixing
> sorry, it was a nova issue. _get_kernel_ramdisk_from_image checks 'disk_format' != 'ami'
>
> I guess that just stresses the importance of having the migrations in place with the branch :)

Ya, I'm currently working on the migration script. should be done shortly...

> Minor tweak ... ping me and I'll approve immediately after
>
> 278 missing _() ?

Glance doesn't have i18n. Yet :)

Revision history for this message
Sandy Walsh (sandy-walsh) wrote : Posted in a previous version of this proposal

ah! haha

review: Approve
Revision history for this message
Sandy Walsh (sandy-walsh) wrote :

second time's a charm

review: Approve
Revision history for this message
Thierry Carrez (ttx) wrote :

Essential feature. FFE preapproved for late merging until Tuesday EOD

review: Approve (ffe)
Revision history for this message
Rick Harris (rconradharris) wrote :

Really nice job, Jay. Very clean implementation, nice test coverage, and great docs. Bravo.

review: Approve
Revision history for this message
Jay Pipes (jaypipes) wrote :

approving the stop_servers() fix.. looks like rick approved the MP right before that fix changed the diff...

review: Approve
Revision history for this message
Cory Wright (corywright) wrote :

Looks great and is working for me. Nice work, Jay!

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'doc/source/glanceapi.rst'
2--- doc/source/glanceapi.rst 2011-03-05 17:04:43 +0000
3+++ doc/source/glanceapi.rst 2011-03-25 19:01:10 +0000
4@@ -67,6 +67,7 @@
5 'disk_format': 'vhd',
6 'container_format': 'ovf',
7 'size': '5368709120',
8+ 'checksum': 'c2e5db72bd7fd153f53ede5da5a06de3',
9 'location': 'swift://account:key/container/image.tar.gz.0',
10 'created_at': '2010-02-03 09:34:01',
11 'updated_at': '2010-02-03 09:34:01',
12@@ -89,6 +90,8 @@
13 The `properties` field is a mapping of free-form key/value pairs that
14 have been saved with the image metadata
15
16+ The `checksum` field is an MD5 checksum of the image file data
17+
18
19 Requesting Detailed Metadata on a Specific Image
20 ------------------------------------------------
21@@ -116,6 +119,7 @@
22 x-image-meta-disk-format vhd
23 x-image-meta-container-format ovf
24 x-image-meta-size 5368709120
25+ x-image-meta-checksum c2e5db72bd7fd153f53ede5da5a06de3
26 x-image-meta-location swift://account:key/container/image.tar.gz.0
27 x-image-meta-created_at 2010-02-03 09:34:01
28 x-image-meta-updated_at 2010-02-03 09:34:01
29@@ -137,6 +141,9 @@
30 that have been saved with the image metadata. The key is the string
31 after `x-image-meta-property-` and the value is the value of the header
32
33+ The response's `ETag` header will always be equal to the
34+ `x-image-meta-checksum` value
35+
36
37 Retrieving a Virtual Machine Image
38 ----------------------------------
39@@ -166,6 +173,7 @@
40 x-image-meta-disk-format vhd
41 x-image-meta-container-format ovf
42 x-image-meta-size 5368709120
43+ x-image-meta-checksum c2e5db72bd7fd153f53ede5da5a06de3
44 x-image-meta-location swift://account:key/container/image.tar.gz.0
45 x-image-meta-created_at 2010-02-03 09:34:01
46 x-image-meta-updated_at 2010-02-03 09:34:01
47@@ -190,6 +198,9 @@
48 The response's `Content-Length` header shall be equal to the value of
49 the `x-image-meta-size` header
50
51+ The response's `ETag` header will always be equal to the
52+ `x-image-meta-checksum` value
53+
54 The image data itself will be the body of the HTTP response returned
55 from the request, which will have content-type of
56 `application/octet-stream`.
57@@ -284,6 +295,15 @@
58 When not present, Glance will calculate the image's size based on the size
59 of the request body.
60
61+* ``x-image-meta-checksum``
62+
63+ This header is optional. When present it shall be the expected **MD5**
64+ checksum of the image file data.
65+
66+ When present, Glance will verify the checksum generated from the backend
67+ store when storing your image against this value and return a
68+ **400 Bad Request** if the values do not match.
69+
70 * ``x-image-meta-is-public``
71
72 This header is optional.
73
74=== modified file 'glance/client.py'
75--- glance/client.py 2011-03-06 17:02:44 +0000
76+++ glance/client.py 2011-03-25 19:01:10 +0000
77@@ -142,7 +142,10 @@
78 c.request(method, action, body, headers)
79 res = c.getresponse()
80 status_code = self.get_status_code(res)
81- if status_code == httplib.OK:
82+ if status_code in (httplib.OK,
83+ httplib.CREATED,
84+ httplib.ACCEPTED,
85+ httplib.NO_CONTENT):
86 return res
87 elif status_code == httplib.UNAUTHORIZED:
88 raise exception.NotAuthorized
89
90=== modified file 'glance/registry/db/api.py'
91--- glance/registry/db/api.py 2011-03-14 19:10:24 +0000
92+++ glance/registry/db/api.py 2011-03-25 19:01:10 +0000
93@@ -43,7 +43,7 @@
94
95 IMAGE_ATTRS = BASE_MODEL_ATTRS | set(['name', 'status', 'size',
96 'disk_format', 'container_format',
97- 'is_public', 'location'])
98+ 'is_public', 'location', 'checksum'])
99
100 CONTAINER_FORMATS = ['ami', 'ari', 'aki', 'bare', 'ovf']
101 DISK_FORMATS = ['ami', 'ari', 'aki', 'vhd', 'vmdk', 'raw', 'qcow2', 'vdi']
102
103=== added file 'glance/registry/db/migrate_repo/versions/004_add_checksum.py'
104--- glance/registry/db/migrate_repo/versions/004_add_checksum.py 1970-01-01 00:00:00 +0000
105+++ glance/registry/db/migrate_repo/versions/004_add_checksum.py 2011-03-25 19:01:10 +0000
106@@ -0,0 +1,80 @@
107+# vim: tabstop=4 shiftwidth=4 softtabstop=4
108+
109+# Copyright 2011 OpenStack LLC.
110+# All Rights Reserved.
111+#
112+# Licensed under the Apache License, Version 2.0 (the "License"); you may
113+# not use this file except in compliance with the License. You may obtain
114+# a copy of the License at
115+#
116+# http://www.apache.org/licenses/LICENSE-2.0
117+#
118+# Unless required by applicable law or agreed to in writing, software
119+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
120+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
121+# License for the specific language governing permissions and limitations
122+# under the License.
123+
124+from migrate.changeset import *
125+from sqlalchemy import *
126+from sqlalchemy.sql import and_, not_
127+
128+from glance.registry.db.migrate_repo.schema import (
129+ Boolean, DateTime, Integer, String, Text, from_migration_import)
130+
131+
132+def get_images_table(meta):
133+ """
134+ Returns the Table object for the images table that
135+ corresponds to the images table definition of this version.
136+ """
137+ images = Table('images', meta,
138+ Column('id', Integer(), primary_key=True, nullable=False),
139+ Column('name', String(255)),
140+ Column('disk_format', String(20)),
141+ Column('container_format', String(20)),
142+ Column('size', Integer()),
143+ Column('status', String(30), nullable=False),
144+ Column('is_public', Boolean(), nullable=False, default=False,
145+ index=True),
146+ Column('location', Text()),
147+ Column('created_at', DateTime(), nullable=False),
148+ Column('updated_at', DateTime()),
149+ Column('deleted_at', DateTime()),
150+ Column('deleted', Boolean(), nullable=False, default=False,
151+ index=True),
152+ Column('checksum', String(32)),
153+ mysql_engine='InnoDB',
154+ useexisting=True)
155+
156+ return images
157+
158+
159+def get_image_properties_table(meta):
160+ """
161+ No changes to the image properties table from 002...
162+ """
163+ (define_image_properties_table,) = from_migration_import(
164+ '002_add_image_properties_table', ['define_image_properties_table'])
165+
166+ image_properties = define_image_properties_table(meta)
167+ return image_properties
168+
169+
170+def upgrade(migrate_engine):
171+ meta = MetaData()
172+ meta.bind = migrate_engine
173+
174+ images = get_images_table(meta)
175+
176+ checksum = Column('checksum', String(32))
177+ checksum.create(images)
178+
179+
180+def downgrade(migrate_engine):
181+ meta = MetaData()
182+ meta.bind = migrate_engine
183+
184+ images = get_images_table(meta)
185+
186+ images.columns['checksum'].drop()
187
188=== modified file 'glance/registry/db/models.py'
189--- glance/registry/db/models.py 2011-03-05 17:04:43 +0000
190+++ glance/registry/db/models.py 2011-03-25 19:01:10 +0000
191@@ -104,6 +104,7 @@
192 status = Column(String(30), nullable=False)
193 is_public = Column(Boolean, nullable=False, default=False)
194 location = Column(Text)
195+ checksum = Column(String(32))
196
197
198 class ImageProperty(BASE, ModelBase):
199
200=== modified file 'glance/registry/server.py'
201--- glance/registry/server.py 2011-02-25 16:52:12 +0000
202+++ glance/registry/server.py 2011-03-25 19:01:10 +0000
203@@ -14,8 +14,9 @@
204 # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
205 # License for the specific language governing permissions and limitations
206 # under the License.
207+
208 """
209-Parllax Image controller
210+Reference implementation registry server WSGI controller
211 """
212
213 import json
214@@ -31,6 +32,10 @@
215
216 logger = logging.getLogger('glance.registry.server')
217
218+DISPLAY_FIELDS_IN_INDEX = ['id', 'name', 'size',
219+ 'disk_format', 'container_format',
220+ 'checksum']
221+
222
223 class Controller(wsgi.Controller):
224 """Controller for the reference implementation registry server"""
225@@ -49,16 +54,24 @@
226
227 Where image_list is a sequence of mappings::
228
229- {'id': image_id, 'name': image_name}
230+ {
231+ 'id': <ID>,
232+ 'name': <NAME>,
233+ 'size': <SIZE>,
234+ 'disk_format': <DISK_FORMAT>,
235+ 'container_format': <CONTAINER_FORMAT>,
236+ 'checksum': <CHECKSUM>
237+ }
238
239 """
240 images = db_api.image_get_all_public(None)
241- image_dicts = [dict(id=i['id'],
242- name=i['name'],
243- disk_format=i['disk_format'],
244- container_format=i['container_format'],
245- size=i['size']) for i in images]
246- return dict(images=image_dicts)
247+ results = []
248+ for image in images:
249+ result = {}
250+ for field in DISPLAY_FIELDS_IN_INDEX:
251+ result[field] = image[field]
252+ results.append(result)
253+ return dict(images=results)
254
255 def detail(self, req):
256 """Return detailed information for all public, non-deleted images
257
258=== modified file 'glance/server.py'
259--- glance/server.py 2011-03-24 23:03:17 +0000
260+++ glance/server.py 2011-03-25 19:01:10 +0000
261@@ -29,6 +29,7 @@
262
263 """
264
265+import httplib
266 import json
267 import logging
268 import sys
269@@ -68,8 +69,8 @@
270 GET /images/<ID> -- Return image data for image with id <ID>
271 POST /images -- Store image data and return metadata about the
272 newly-stored image
273- PUT /images/<ID> -- Update image metadata (not image data, since
274- image data is immutable once stored)
275+ PUT /images/<ID> -- Update image metadata and/or upload image
276+ data for a previously-reserved image
277 DELETE /images/<ID> -- Delete the image with id <ID>
278 """
279
280@@ -82,6 +83,9 @@
281
282 * id -- The opaque image identifier
283 * name -- The name of the image
284+ * disk_format -- The disk image format
285+ * container_format -- The "container" format of the image
286+ * checksum -- MD5 checksum of the image data
287 * size -- Size of image data in bytes
288
289 :param request: The WSGI/Webob Request object
290@@ -92,6 +96,7 @@
291 'name': <NAME>,
292 'disk_format': <DISK_FORMAT>,
293 'container_format': <DISK_FORMAT>,
294+ 'checksum': <CHECKSUM>
295 'size': <SIZE>}, ...
296 ]}
297 """
298@@ -111,6 +116,7 @@
299 'size': <SIZE>,
300 'disk_format': <DISK_FORMAT>,
301 'container_format': <CONTAINER_FORMAT>,
302+ 'checksum': <CHECKSUM>,
303 'store': <STORE>,
304 'status': <STATUS>,
305 'created_at': <TIMESTAMP>,
306@@ -136,6 +142,8 @@
307
308 res = Response(request=req)
309 utils.inject_image_meta_into_headers(res, image)
310+ res.headers.add('Location', "/images/%s" % id)
311+ res.headers.add('ETag', image['checksum'])
312
313 return req.get_response(res)
314
315@@ -167,6 +175,8 @@
316 # Using app_iter blanks content-length, so we set it here...
317 res.headers.add('Content-Length', image['size'])
318 utils.inject_image_meta_into_headers(res, image)
319+ res.headers.add('Location', "/images/%s" % id)
320+ res.headers.add('ETag', image['checksum'])
321 return req.get_response(res)
322
323 def _reserve(self, req):
324@@ -227,36 +237,45 @@
325
326 store = self.get_store_or_400(req, store_name)
327
328- image_meta['status'] = 'saving'
329 image_id = image_meta['id']
330- logger.debug("Updating image metadata for image %s"
331+ logger.debug("Setting image %s to status 'saving'"
332 % image_id)
333- registry.update_image_metadata(self.options,
334- image_meta['id'],
335- image_meta)
336-
337+ registry.update_image_metadata(self.options, image_id,
338+ {'status': 'saving'})
339 try:
340 logger.debug("Uploading image data for image %(image_id)s "
341 "to %(store_name)s store" % locals())
342- location, size = store.add(image_meta['id'],
343- req.body_file,
344- self.options)
345- # If size returned from store is different from size
346- # already stored in registry, update the registry with
347- # the new size of the image
348- if image_meta.get('size', 0) != size:
349- image_meta['size'] = size
350- logger.debug("Updating image metadata for image %s"
351- % image_id)
352- registry.update_image_metadata(self.options,
353- image_meta['id'],
354- image_meta)
355+ location, size, checksum = store.add(image_meta['id'],
356+ req.body_file,
357+ self.options)
358+
359+ # Verify any supplied checksum value matches checksum
360+ # returned from store when adding image
361+ supplied_checksum = image_meta.get('checksum')
362+ if supplied_checksum and supplied_checksum != checksum:
363+ msg = ("Supplied checksum (%(supplied_checksum)s) and "
364+ "checksum generated from uploaded image "
365+ "(%(checksum)s) did not match. Setting image "
366+ "status to 'killed'.") % locals()
367+ self._safe_kill(req, image_meta)
368+ raise HTTPBadRequest(msg, content_type="text/plain",
369+ request=req)
370+
371+ # Update the database with the checksum returned
372+ # from the backend store
373+ logger.debug("Updating image %(image_id)s data. "
374+ "Checksum set to %(checksum)s, size set "
375+ "to %(size)d" % locals())
376+ registry.update_image_metadata(self.options, image_id,
377+ {'checksum': checksum,
378+ 'size': size})
379+
380 return location
381 except exception.Duplicate, e:
382 logger.error("Error adding image to store: %s", str(e))
383 raise HTTPConflict(str(e), request=req)
384
385- def _activate(self, req, image_meta, location):
386+ def _activate(self, req, image_id, location):
387 """
388 Sets the image status to `active` and the image's location
389 attribute.
390@@ -265,25 +284,25 @@
391 :param image_meta: Mapping of metadata about image
392 :param location: Location of where Glance stored this image
393 """
394+ image_meta = {}
395 image_meta['location'] = location
396 image_meta['status'] = 'active'
397- registry.update_image_metadata(self.options,
398- image_meta['id'],
399+ return registry.update_image_metadata(self.options,
400+ image_id,
401 image_meta)
402
403- def _kill(self, req, image_meta):
404+ def _kill(self, req, image_id):
405 """
406 Marks the image status to `killed`
407
408 :param request: The WSGI/Webob Request object
409- :param image_meta: Mapping of metadata about image
410+ :param image_id: Opaque image identifier
411 """
412- image_meta['status'] = 'killed'
413 registry.update_image_metadata(self.options,
414- image_meta['id'],
415- image_meta)
416+ image_id,
417+ {'status': 'killed'})
418
419- def _safe_kill(self, req, image_meta):
420+ def _safe_kill(self, req, image_id):
421 """
422 Mark image killed without raising exceptions if it fails.
423
424@@ -291,12 +310,13 @@
425 not raise itself, rather it should just log its error.
426
427 :param request: The WSGI/Webob Request object
428+ :param image_id: Opaque image identifier
429 """
430 try:
431- self._kill(req, image_meta)
432+ self._kill(req, image_id)
433 except Exception, e:
434 logger.error("Unable to kill image %s: %s",
435- image_meta['id'], repr(e))
436+ image_id, repr(e))
437
438 def _upload_and_activate(self, req, image_meta):
439 """
440@@ -306,13 +326,16 @@
441
442 :param request: The WSGI/Webob Request object
443 :param image_meta: Mapping of metadata about image
444+
445+ :retval Mapping of updated image data
446 """
447 try:
448+ image_id = image_meta['id']
449 location = self._upload(req, image_meta)
450- self._activate(req, image_meta, location)
451+ return self._activate(req, image_id, location)
452 except: # unqualified b/c we're re-raising it
453 exc_type, exc_value, exc_traceback = sys.exc_info()
454- self._safe_kill(req, image_meta)
455+ self._safe_kill(req, image_id)
456 # NOTE(sirp): _safe_kill uses httplib which, in turn, uses
457 # Eventlet's GreenSocket. Eventlet subsequently clears exceptions
458 # by calling `sys.exc_clear()`.
459@@ -356,19 +379,21 @@
460 image data.
461 """
462 image_meta = self._reserve(req)
463+ image_id = image_meta['id']
464
465 if utils.has_body(req):
466- self._upload_and_activate(req, image_meta)
467+ image_meta = self._upload_and_activate(req, image_meta)
468 else:
469 if 'x-image-meta-location' in req.headers:
470 location = req.headers['x-image-meta-location']
471- self._activate(req, image_meta, location)
472+ image_meta = self._activate(req, image_id, location)
473
474 # APP states we should return a Location: header with the edit
475 # URI of the resource newly-created.
476 res = Response(request=req, body=json.dumps(dict(image=image_meta)),
477- content_type="text/plain")
478- res.headers.add('Location', "/images/%s" % image_meta['id'])
479+ status=httplib.CREATED, content_type="text/plain")
480+ res.headers.add('Location', "/images/%s" % image_id)
481+ res.headers.add('ETag', image_meta['checksum'])
482
483 return req.get_response(res)
484
485@@ -395,9 +420,14 @@
486 id,
487 new_image_meta)
488 if has_body:
489- self._upload_and_activate(req, image_meta)
490+ image_meta = self._upload_and_activate(req, image_meta)
491
492- return dict(image=image_meta)
493+ res = Response(request=req,
494+ body=json.dumps(dict(image=image_meta)),
495+ content_type="text/plain")
496+ res.headers.add('Location', "/images/%s" % id)
497+ res.headers.add('ETag', image_meta['checksum'])
498+ return res
499 except exception.Invalid, e:
500 msg = ("Failed to update image metadata. Got error: %(e)s"
501 % locals())
502
503=== modified file 'glance/store/__init__.py'
504--- glance/store/__init__.py 2011-02-24 02:50:24 +0000
505+++ glance/store/__init__.py 2011-03-25 19:01:10 +0000
506@@ -141,40 +141,3 @@
507 authurl = "https://%s" % '/'.join(path_parts)
508
509 return user, key, authurl, container, obj
510-
511-
512-def add_options(parser):
513- """
514- Adds any configuration options that each store might
515- have.
516-
517- :param parser: An optparse.OptionParser object
518- :retval None
519- """
520- # TODO(jaypipes): Remove these imports...
521- from glance.store.http import HTTPBackend
522- from glance.store.s3 import S3Backend
523- from glance.store.swift import SwiftBackend
524- from glance.store.filesystem import FilesystemBackend
525-
526- help_text = "The following configuration options are specific to the "\
527- "Glance image store."
528-
529- DEFAULT_STORE_CHOICES = ['file', 'swift', 's3']
530- group = optparse.OptionGroup(parser, "Image Store Options", help_text)
531- group.add_option('--default-store', metavar="STORE",
532- default="file",
533- choices=DEFAULT_STORE_CHOICES,
534- help="The backend store that Glance will use to store "
535- "virtual machine images to. Choices: ('%s') "
536- "Default: %%default" % "','".join(DEFAULT_STORE_CHOICES))
537-
538- backend_classes = [FilesystemBackend,
539- HTTPBackend,
540- SwiftBackend,
541- S3Backend]
542- for backend_class in backend_classes:
543- if hasattr(backend_class, 'add_options'):
544- backend_class.add_options(group)
545-
546- parser.add_option_group(group)
547
548=== modified file 'glance/store/filesystem.py'
549--- glance/store/filesystem.py 2011-02-27 20:54:29 +0000
550+++ glance/store/filesystem.py 2011-03-25 19:01:10 +0000
551@@ -19,6 +19,7 @@
552 A simple filesystem-backed store
553 """
554
555+import hashlib
556 import logging
557 import os
558 import urlparse
559@@ -110,9 +111,10 @@
560 :param data: The image data to write, as a file-like object
561 :param options: Conf mapping
562
563- :retval Tuple with (location, size)
564- The location that was written, with file:// scheme prepended
565- and the size in bytes of the data written
566+ :retval Tuple with (location, size, checksum)
567+ The location that was written, with file:// scheme prepended,
568+ the size in bytes of the data written, and the checksum of
569+ the image added.
570 """
571 datadir = options['filesystem_store_datadir']
572
573@@ -127,6 +129,7 @@
574 raise exception.Duplicate("Image file %s already exists!"
575 % filepath)
576
577+ checksum = hashlib.md5()
578 bytes_written = 0
579 with open(filepath, 'wb') as f:
580 while True:
581@@ -134,23 +137,11 @@
582 if not buf:
583 break
584 bytes_written += len(buf)
585+ checksum.update(buf)
586 f.write(buf)
587
588- logger.debug("Wrote %(bytes_written)d bytes to %(filepath)s"
589- % locals())
590- return ('file://%s' % filepath, bytes_written)
591-
592- @classmethod
593- def add_options(cls, parser):
594- """
595- Adds specific configuration options for this store
596-
597- :param parser: An optparse.OptionParser object
598- :retval None
599- """
600-
601- parser.add_option('--filesystem-store-datadir', metavar="DIR",
602- default="/var/lib/glance/images/",
603- help="Location to write image data. This directory "
604- "should be writeable by the user that runs the "
605- "glance-api program. Default: %default")
606+ checksum_hex = checksum.hexdigest()
607+
608+ logger.debug("Wrote %(bytes_written)d bytes to %(filepath)s with "
609+ "checksum %(checksum_hex)s" % locals())
610+ return ('file://%s' % filepath, bytes_written, checksum_hex)
611
612=== modified file 'glance/store/swift.py'
613--- glance/store/swift.py 2011-03-16 16:45:01 +0000
614+++ glance/store/swift.py 2011-03-25 19:01:10 +0000
615@@ -161,7 +161,7 @@
616 # header keys are lowercased by Swift
617 if 'content-length' in resp_headers:
618 size = int(resp_headers['content-length'])
619- return (location, size)
620+ return (location, size, obj_etag)
621 except swift_client.ClientException, e:
622 if e.http_status == httplib.CONFLICT:
623 raise exception.Duplicate("Swift already has an image at "
624
625=== modified file 'tests/functional/__init__.py'
626--- tests/functional/__init__.py 2011-03-17 22:15:41 +0000
627+++ tests/functional/__init__.py 2011-03-25 19:01:10 +0000
628@@ -203,14 +203,14 @@
629 """
630
631 # Spin down the API and default registry server
632- cmd = ("./bin/glance-control api start "
633+ cmd = ("./bin/glance-control api stop "
634 "%(conf_file_name)s --pid-file=%(api_pid_file)s"
635 % self.__dict__)
636 exitcode, out, err = execute(cmd)
637 self.assertEqual(0, exitcode,
638 "Failed to spin down the API server. "
639 "Got: %s" % err)
640- cmd = ("./bin/glance-control registry start "
641+ cmd = ("./bin/glance-control registry stop "
642 "%(conf_file_name)s --pid-file=%(registry_pid_file)s"
643 % self.__dict__)
644 exitcode, out, err = execute(cmd)
645
646=== modified file 'tests/functional/test_curl_api.py'
647--- tests/functional/test_curl_api.py 2011-03-17 21:43:26 +0000
648+++ tests/functional/test_curl_api.py 2011-03-25 19:01:10 +0000
649@@ -110,7 +110,7 @@
650 lines = out.split("\r\n")
651 status_line = lines[0]
652
653- self.assertEqual("HTTP/1.1 200 OK", status_line)
654+ self.assertEqual("HTTP/1.1 201 Created", status_line)
655
656 # 4. HEAD /images
657 # Verify image found now
658@@ -214,6 +214,7 @@
659 "disk_format": None,
660 "id": 1,
661 "name": "Image1",
662+ "checksum": "c2e5db72bd7fd153f53ede5da5a06de3",
663 "size": 5120}]}
664 self.assertEqual(expected_result, json.loads(out.strip()))
665
666
667=== modified file 'tests/stubs.py'
668--- tests/stubs.py 2011-03-14 19:10:24 +0000
669+++ tests/stubs.py 2011-03-25 19:01:10 +0000
670@@ -282,6 +282,7 @@
671 'updated_at': datetime.datetime.utcnow(),
672 'deleted_at': None,
673 'deleted': False,
674+ 'checksum': None,
675 'size': 13,
676 'location': "swift://user:passwd@acct/container/obj.tar.0",
677 'properties': [{'key': 'type',
678@@ -297,6 +298,7 @@
679 'updated_at': datetime.datetime.utcnow(),
680 'deleted_at': None,
681 'deleted': False,
682+ 'checksum': None,
683 'size': 19,
684 'location': "file:///tmp/glance-tests/2",
685 'properties': []}]
686@@ -316,6 +318,7 @@
687 glance.registry.db.api.validate_image(values)
688
689 values['size'] = values.get('size', 0)
690+ values['checksum'] = values.get('checksum')
691 values['deleted'] = False
692 values['properties'] = values.get('properties', {})
693 values['created_at'] = datetime.datetime.utcnow()
694@@ -348,6 +351,7 @@
695 copy_image.update(values)
696 glance.registry.db.api.validate_image(copy_image)
697 props = []
698+ orig_properties = image['properties']
699
700 if 'properties' in values.keys():
701 for k, v in values['properties'].items():
702@@ -360,7 +364,8 @@
703 p['deleted_at'] = None
704 props.append(p)
705
706- values['properties'] = props
707+ orig_properties = orig_properties + props
708+ values['properties'] = orig_properties
709
710 image.update(values)
711 return image
712
713=== modified file 'tests/unit/test_api.py'
714--- tests/unit/test_api.py 2011-03-14 19:10:24 +0000
715+++ tests/unit/test_api.py 2011-03-25 19:01:10 +0000
716@@ -15,8 +15,9 @@
717 # License for the specific language governing permissions and limitations
718 # under the License.
719
720+import hashlib
721+import httplib
722 import os
723-import httplib
724 import json
725 import unittest
726
727@@ -52,7 +53,9 @@
728
729 """
730 fixture = {'id': 2,
731- 'name': 'fake image #2'}
732+ 'name': 'fake image #2',
733+ 'size': 19,
734+ 'checksum': None}
735 req = webob.Request.blank('/')
736 res = req.get_response(self.api)
737 res_dict = json.loads(res.body)
738@@ -70,7 +73,9 @@
739
740 """
741 fixture = {'id': 2,
742- 'name': 'fake image #2'}
743+ 'name': 'fake image #2',
744+ 'size': 19,
745+ 'checksum': None}
746 req = webob.Request.blank('/images')
747 res = req.get_response(self.api)
748 res_dict = json.loads(res.body)
749@@ -90,6 +95,8 @@
750 fixture = {'id': 2,
751 'name': 'fake image #2',
752 'is_public': True,
753+ 'size': 19,
754+ 'checksum': None,
755 'disk_format': 'vhd',
756 'container_format': 'ovf',
757 'status': 'active'}
758@@ -396,7 +403,7 @@
759 for k, v in fixture_headers.iteritems():
760 req.headers[k] = v
761 res = req.get_response(self.api)
762- self.assertEquals(res.status_int, httplib.OK)
763+ self.assertEquals(res.status_int, httplib.CREATED)
764
765 res_body = json.loads(res.body)['image']
766 self.assertEquals('queued', res_body['status'])
767@@ -431,7 +438,7 @@
768 req.headers['Content-Type'] = 'application/octet-stream'
769 req.body = "chunk00000remainder"
770 res = req.get_response(self.api)
771- self.assertEquals(res.status_int, 200)
772+ self.assertEquals(res.status_int, httplib.CREATED)
773
774 res_body = json.loads(res.body)['image']
775 self.assertEquals(res_body['location'],
776@@ -445,6 +452,97 @@
777 "res.headerlist = %r" % res.headerlist)
778 self.assertTrue('/images/3' in res.headers['location'])
779
780+ def test_image_is_checksummed(self):
781+ """Test that the image contents are checksummed properly"""
782+ fixture_headers = {'x-image-meta-store': 'file',
783+ 'x-image-meta-disk-format': 'vhd',
784+ 'x-image-meta-container-format': 'ovf',
785+ 'x-image-meta-name': 'fake image #3'}
786+ image_contents = "chunk00000remainder"
787+ image_checksum = hashlib.md5(image_contents).hexdigest()
788+
789+ req = webob.Request.blank("/images")
790+ req.method = 'POST'
791+ for k, v in fixture_headers.iteritems():
792+ req.headers[k] = v
793+
794+ req.headers['Content-Type'] = 'application/octet-stream'
795+ req.body = image_contents
796+ res = req.get_response(self.api)
797+ self.assertEquals(res.status_int, httplib.CREATED)
798+
799+ res_body = json.loads(res.body)['image']
800+ self.assertEquals(res_body['location'],
801+ 'file:///tmp/glance-tests/3')
802+ self.assertEquals(image_checksum, res_body['checksum'],
803+ "Mismatched checksum. Expected %s, got %s" %
804+ (image_checksum, res_body['checksum']))
805+
806+ def test_etag_equals_checksum_header(self):
807+ """Test that the ETag header matches the x-image-meta-checksum"""
808+ fixture_headers = {'x-image-meta-store': 'file',
809+ 'x-image-meta-disk-format': 'vhd',
810+ 'x-image-meta-container-format': 'ovf',
811+ 'x-image-meta-name': 'fake image #3'}
812+ image_contents = "chunk00000remainder"
813+ image_checksum = hashlib.md5(image_contents).hexdigest()
814+
815+ req = webob.Request.blank("/images")
816+ req.method = 'POST'
817+ for k, v in fixture_headers.iteritems():
818+ req.headers[k] = v
819+
820+ req.headers['Content-Type'] = 'application/octet-stream'
821+ req.body = image_contents
822+ res = req.get_response(self.api)
823+ self.assertEquals(res.status_int, httplib.CREATED)
824+
825+ # HEAD the image and check the ETag equals the checksum header...
826+ expected_headers = {'x-image-meta-checksum': image_checksum,
827+ 'etag': image_checksum}
828+ req = webob.Request.blank("/images/3")
829+ req.method = 'HEAD'
830+ res = req.get_response(self.api)
831+ self.assertEquals(res.status_int, 200)
832+
833+ for key in expected_headers.keys():
834+ self.assertTrue(key in res.headers,
835+ "required header '%s' missing from "
836+ "returned headers" % key)
837+ for key, value in expected_headers.iteritems():
838+ self.assertEquals(value, res.headers[key])
839+
840+ def test_bad_checksum_kills_image(self):
841+ """Test that the image contents are checksummed properly"""
842+ image_contents = "chunk00000remainder"
843+ bad_checksum = hashlib.md5("invalid").hexdigest()
844+ fixture_headers = {'x-image-meta-store': 'file',
845+ 'x-image-meta-disk-format': 'vhd',
846+ 'x-image-meta-container-format': 'ovf',
847+ 'x-image-meta-name': 'fake image #3',
848+ 'x-image-meta-checksum': bad_checksum}
849+
850+ req = webob.Request.blank("/images")
851+ req.method = 'POST'
852+ for k, v in fixture_headers.iteritems():
853+ req.headers[k] = v
854+
855+ req.headers['Content-Type'] = 'application/octet-stream'
856+ req.body = image_contents
857+ res = req.get_response(self.api)
858+ self.assertEquals(res.status_int, webob.exc.HTTPBadRequest.code)
859+
860+ # Test the image was killed...
861+ expected_headers = {'x-image-meta-id': '3',
862+ 'x-image-meta-status': 'killed'}
863+ req = webob.Request.blank("/images/3")
864+ req.method = 'HEAD'
865+ res = req.get_response(self.api)
866+ self.assertEquals(res.status_int, 200)
867+
868+ for key, value in expected_headers.iteritems():
869+ self.assertEquals(value, res.headers[key])
870+
871 def test_image_meta(self):
872 """Test for HEAD /images/<ID>"""
873 expected_headers = {'x-image-meta-id': '2',
874
875=== modified file 'tests/unit/test_filesystem_store.py'
876--- tests/unit/test_filesystem_store.py 2011-02-27 20:54:29 +0000
877+++ tests/unit/test_filesystem_store.py 2011-03-25 19:01:10 +0000
878@@ -18,6 +18,7 @@
879 """Tests the filesystem backend store"""
880
881 import StringIO
882+import hashlib
883 import unittest
884 import urlparse
885
886@@ -27,6 +28,11 @@
887 from glance.store.filesystem import FilesystemBackend, ChunkedFile
888 from tests import stubs
889
890+FILESYSTEM_OPTIONS = {
891+ 'verbose': True,
892+ 'debug': True,
893+ 'filesystem_store_datadir': stubs.FAKE_FILESYSTEM_ROOTDIR}
894+
895
896 class TestFilesystemBackend(unittest.TestCase):
897
898@@ -75,17 +81,17 @@
899 expected_image_id = 42
900 expected_file_size = 1024 * 5 # 5K
901 expected_file_contents = "*" * expected_file_size
902+ expected_checksum = hashlib.md5(expected_file_contents).hexdigest()
903 expected_location = "file://%s/%s" % (stubs.FAKE_FILESYSTEM_ROOTDIR,
904 expected_image_id)
905 image_file = StringIO.StringIO(expected_file_contents)
906- options = {'verbose': True,
907- 'debug': True,
908- 'filesystem_store_datadir': stubs.FAKE_FILESYSTEM_ROOTDIR}
909
910- location, size = FilesystemBackend.add(42, image_file, options)
911+ location, size, checksum = FilesystemBackend.add(42, image_file,
912+ FILESYSTEM_OPTIONS)
913
914 self.assertEquals(expected_location, location)
915 self.assertEquals(expected_file_size, size)
916+ self.assertEquals(expected_checksum, checksum)
917
918 url_pieces = urlparse.urlparse("file:///tmp/glance-tests/42")
919 new_image_file = FilesystemBackend.get(url_pieces)
920@@ -110,7 +116,7 @@
921 'filesystem_store_datadir': stubs.FAKE_FILESYSTEM_ROOTDIR}
922 self.assertRaises(exception.Duplicate,
923 FilesystemBackend.add,
924- 2, image_file, options)
925+ 2, image_file, FILESYSTEM_OPTIONS)
926
927 def test_delete(self):
928 """
929
930=== modified file 'tests/unit/test_swift_store.py'
931--- tests/unit/test_swift_store.py 2011-03-16 16:45:01 +0000
932+++ tests/unit/test_swift_store.py 2011-03-25 19:01:10 +0000
933@@ -70,15 +70,20 @@
934 if hasattr(contents, 'read'):
935 fixture_object = StringIO.StringIO()
936 chunk = contents.read(SwiftBackend.CHUNKSIZE)
937+ checksum = hashlib.md5()
938 while chunk:
939 fixture_object.write(chunk)
940+ checksum.update(chunk)
941 chunk = contents.read(SwiftBackend.CHUNKSIZE)
942+ etag = checksum.hexdigest()
943 else:
944 fixture_object = StringIO.StringIO(contents)
945+ etag = hashlib.md5(fixture_object.getvalue()).hexdigest()
946+ read_len = fixture_object.len
947 fixture_objects[fixture_key] = fixture_object
948 fixture_headers[fixture_key] = {
949- 'content-length': fixture_object.len,
950- 'etag': hashlib.md5(fixture_object.read()).hexdigest()}
951+ 'content-length': read_len,
952+ 'etag': etag}
953 return fixture_headers[fixture_key]['etag']
954 else:
955 msg = ("Object PUT failed - Object with key %s already exists"
956@@ -226,8 +231,9 @@
957 def test_add(self):
958 """Test that we can add an image via the swift backend"""
959 expected_image_id = 42
960- expected_swift_size = 1024 * 5 # 5K
961+ expected_swift_size = FIVE_KB
962 expected_swift_contents = "*" * expected_swift_size
963+ expected_checksum = hashlib.md5(expected_swift_contents).hexdigest()
964 expected_location = format_swift_location(
965 SWIFT_OPTIONS['swift_store_user'],
966 SWIFT_OPTIONS['swift_store_key'],
967@@ -236,10 +242,12 @@
968 expected_image_id)
969 image_swift = StringIO.StringIO(expected_swift_contents)
970
971- location, size = SwiftBackend.add(42, image_swift, SWIFT_OPTIONS)
972+ location, size, checksum = SwiftBackend.add(42, image_swift,
973+ SWIFT_OPTIONS)
974
975 self.assertEquals(expected_location, location)
976 self.assertEquals(expected_swift_size, size)
977+ self.assertEquals(expected_checksum, checksum)
978
979 url_pieces = urlparse.urlparse(expected_location)
980 new_image_swift = SwiftBackend.get(url_pieces)
981@@ -280,8 +288,9 @@
982 options['swift_store_create_container_on_put'] = 'True'
983 options['swift_store_container'] = 'noexist'
984 expected_image_id = 42
985- expected_swift_size = 1024 * 5 # 5K
986+ expected_swift_size = FIVE_KB
987 expected_swift_contents = "*" * expected_swift_size
988+ expected_checksum = hashlib.md5(expected_swift_contents).hexdigest()
989 expected_location = format_swift_location(
990 options['swift_store_user'],
991 options['swift_store_key'],
992@@ -290,10 +299,12 @@
993 expected_image_id)
994 image_swift = StringIO.StringIO(expected_swift_contents)
995
996- location, size = SwiftBackend.add(42, image_swift, options)
997+ location, size, checksum = SwiftBackend.add(42, image_swift,
998+ options)
999
1000 self.assertEquals(expected_location, location)
1001 self.assertEquals(expected_swift_size, size)
1002+ self.assertEquals(expected_checksum, checksum)
1003
1004 url_pieces = urlparse.urlparse(expected_location)
1005 new_image_swift = SwiftBackend.get(url_pieces)

Subscribers

People subscribed via source and target branches