Merge lp:~ttx/glance/d3-merge into lp:~hudson-openstack/glance/milestone-proposed

Proposed by Thierry Carrez
Status: Merged
Approved by: Thierry Carrez
Approved revision: 143
Merged at revision: 143
Proposed branch: lp:~ttx/glance/d3-merge
Merge into: lp:~hudson-openstack/glance/milestone-proposed
Diff against target: 7532 lines (+4952/-565)
63 files modified
Authors (+3/-0)
bin/glance (+374/-60)
bin/glance-cache-prefetcher (+65/-0)
bin/glance-cache-pruner (+65/-0)
bin/glance-cache-reaper (+73/-0)
bin/glance-control (+4/-3)
bin/glance-scrubber (+80/-0)
etc/glance-api.conf (+25/-1)
etc/glance-prefetcher.conf (+21/-0)
etc/glance-pruner.conf (+28/-0)
etc/glance-reaper.conf (+29/-0)
etc/glance-registry.conf (+7/-1)
etc/glance-scrubber.conf (+36/-0)
glance/api/__init__.py (+47/-0)
glance/api/cached_images.py (+105/-0)
glance/api/middleware/image_cache.py (+57/-0)
glance/api/middleware/version_negotiation.py (+1/-1)
glance/api/v1/__init__.py (+1/-1)
glance/api/v1/images.py (+118/-50)
glance/client.py (+119/-5)
glance/common/client.py (+5/-1)
glance/common/context.py (+97/-0)
glance/common/exception.py (+18/-0)
glance/image_cache/__init__.py (+451/-0)
glance/image_cache/prefetcher.py (+90/-0)
glance/image_cache/pruner.py (+115/-0)
glance/image_cache/reaper.py (+45/-0)
glance/registry/__init__.py (+17/-16)
glance/registry/client.py (+3/-3)
glance/registry/db/api.py (+71/-24)
glance/registry/db/migrate_repo/schema.py (+2/-2)
glance/registry/db/migrate_repo/versions/007_add_owner.py (+82/-0)
glance/registry/db/migration.py (+12/-6)
glance/registry/db/models.py (+10/-0)
glance/registry/server.py (+87/-21)
glance/store/__init__.py (+39/-55)
glance/store/filesystem.py (+50/-12)
glance/store/http.py (+80/-7)
glance/store/location.py (+182/-0)
glance/store/s3.py (+119/-29)
glance/store/scrubber.py (+90/-0)
glance/store/swift.py (+145/-86)
glance/utils.py (+161/-0)
run_tests.py (+8/-4)
setup.py (+3/-0)
tests/functional/__init__.py (+62/-9)
tests/functional/test_bin_glance.py (+0/-4)
tests/functional/test_curl_api.py (+3/-6)
tests/functional/test_httplib2_api.py (+806/-2)
tests/functional/test_logging.py (+0/-2)
tests/functional/test_misc.py (+0/-1)
tests/functional/test_scrubber.py (+111/-0)
tests/stubs.py (+35/-19)
tests/unit/test_api.py (+162/-49)
tests/unit/test_clients.py (+32/-11)
tests/unit/test_config.py (+1/-1)
tests/unit/test_context.py (+148/-0)
tests/unit/test_filesystem_store.py (+12/-13)
tests/unit/test_store_location.py (+243/-0)
tests/unit/test_stores.py (+0/-1)
tests/unit/test_swift_store.py (+94/-58)
tools/install_venv.py (+2/-1)
tools/pip-requires (+1/-0)
To merge this branch: bzr merge lp:~ttx/glance/d3-merge
Reviewer Review Type Date Requested Status
Thierry Carrez Approve
Review via email: mp+69237@code.launchpad.net

Commit message

Merge diablo-3 development from trunk (rev160)

Description of the change

Merge diablo-3 development from trunk (rev160)

This really should be automated.
Check using 'bzr diff --old lp:glance --new lp:~ttx/glance/d3-merge'

To post a comment you must log in.
Revision history for this message
Thierry Carrez (ttx) wrote :

Verified identical to lp:glance

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'Authors'
2--- Authors 2011-06-27 14:37:40 +0000
3+++ Authors 2011-07-26 10:13:35 +0000
4@@ -6,11 +6,14 @@
5 Donal Lafferty <donal.lafferty@citrix.com>
6 Eldar Nugaev <enugaev@griddynamics.com>
7 Ewan Mellor <ewan.mellor@citrix.com>
8+Isaku Yamahata <yamahata@valinux.co.jp>
9+Jason Koelker <jason@koelker.net>
10 Jay Pipes <jaypipes@gmail.com>
11 Jinwoo 'Joseph' Suh <jsuh@isi.edu>
12 Josh Kearney <josh@jk0.org>
13 Justin Shepherd <jshepher@rackspace.com>
14 Ken Pepple <ken.pepple@gmail.com>
15+Kevin L. Mitchell <kevin.mitchell@rackspace.com>
16 Matt Dietz <matt.dietz@rackspace.com>
17 Monty Taylor <mordred@inaugust.com>
18 Rick Clark <rick@openstack.org>
19
20=== modified file 'bin/glance'
21--- bin/glance 2011-06-26 20:43:41 +0000
22+++ bin/glance 2011-07-26 10:13:35 +0000
23@@ -22,6 +22,7 @@
24 stored in one or more Glance nodes.
25 """
26
27+import functools
28 import optparse
29 import os
30 import re
31@@ -36,15 +37,45 @@
32 if os.path.exists(os.path.join(possible_topdir, 'glance', '__init__.py')):
33 sys.path.insert(0, possible_topdir)
34
35-from glance import client
36+from glance import client as glance_client
37 from glance import version
38 from glance.common import exception
39-from glance.common import utils
40+from glance.common import utils as common_utils
41+from glance import utils
42
43 SUCCESS = 0
44 FAILURE = 1
45
46
47+#TODO(sirp): make more of the actions use this decorator
48+def catch_error(action):
49+ """Decorator to provide sensible default error handling for actions."""
50+ def wrap(func):
51+ @functools.wraps(func)
52+ def wrapper(*args, **kwargs):
53+ try:
54+ ret = func(*args, **kwargs)
55+ return SUCCESS if ret is None else ret
56+ except Exception, e:
57+ print "Failed to %s. Got error:" % action
58+ pieces = str(e).split('\n')
59+ for piece in pieces:
60+ print piece
61+ return FAILURE
62+
63+ return wrapper
64+ return wrap
65+
66+
67+def get_percent_done(image):
68+ try:
69+ pct_done = image['size'] * 100 / int(image['expected_size'])
70+ except (ValueError, ZeroDivisionError):
71+ # NOTE(sirp): Ignore if expected_size isn't a number, or if it's 0
72+ pct_done = "UNKNOWN"
73+ return pct_done
74+
75+
76 def get_image_fields_from_args(args):
77 """
78 Validate the set of arguments passed as field name/value pairs
79@@ -152,7 +183,7 @@
80 return FAILURE
81
82 image_meta = {'name': fields.pop('name'),
83- 'is_public': utils.bool_from_string(
84+ 'is_public': common_utils.bool_from_string(
85 fields.pop('is_public', False)),
86 'disk_format': fields.pop('disk_format', 'raw'),
87 'container_format': fields.pop('container_format', 'ovf')}
88@@ -276,7 +307,7 @@
89
90 # Have to handle "boolean" values specially...
91 if 'is_public' in fields:
92- image_meta['is_public'] = utils.bool_from_string(
93+ image_meta['is_public'] = common_utils.bool_from_string(
94 fields.pop('is_public'))
95
96 # Add custom attributes, which are all the arguments remaining
97@@ -364,40 +395,37 @@
98 return FAILURE
99
100
101+@catch_error('show index')
102 def images_index(options, args):
103 """
104 %(prog)s index [options]
105
106 Returns basic information for all public images
107 a Glance server knows about"""
108- c = get_client(options)
109- try:
110- images = c.get_images()
111- if len(images) == 0:
112- print "No public images found."
113- return SUCCESS
114-
115- print "Found %d public images..." % len(images)
116- print "%-16s %-30s %-20s %-20s %-14s" % (("ID"),
117- ("Name"),
118- ("Disk Format"),
119- ("Container Format"),
120- ("Size"))
121- print ('-' * 16) + " " + ('-' * 30) + " "\
122- + ('-' * 20) + " " + ('-' * 20) + " " + ('-' * 14)
123- for image in images:
124- print "%-16s %-30s %-20s %-20s %14d" % (image['id'],
125- image['name'],
126- image['disk_format'],
127- image['container_format'],
128- int(image['size']))
129+ client = get_client(options)
130+ images = client.get_images()
131+ if not images:
132+ print "No public images found."
133 return SUCCESS
134- except Exception, e:
135- print "Failed to show index. Got error:"
136- pieces = str(e).split('\n')
137- for piece in pieces:
138- print piece
139- return FAILURE
140+
141+ print "Found %d public images..." % len(images)
142+
143+ pretty_table = utils.PrettyTable()
144+ pretty_table.add_column(16, label="ID")
145+ pretty_table.add_column(30, label="Name")
146+ pretty_table.add_column(20, label="Disk Format")
147+ pretty_table.add_column(20, label="Container Format")
148+ pretty_table.add_column(14, label="Size", just="r")
149+
150+ print pretty_table.make_header()
151+
152+ for image in images:
153+ print pretty_table.make_row(
154+ image['id'],
155+ image['name'],
156+ image['disk_format'],
157+ image['container_format'],
158+ image['size'])
159
160
161 def images_detailed(options, args):
162@@ -458,13 +486,256 @@
163 return SUCCESS
164
165
166+@catch_error('show cached images')
167+def cache_index(options, args):
168+ """
169+%(prog)s cache-index [options]
170+
171+List all images currently cached"""
172+ client = get_client(options)
173+ images = client.get_cached_images()
174+ if not images:
175+ print "No cached images."
176+ return SUCCESS
177+
178+ print "Found %d cached images..." % len(images)
179+
180+ pretty_table = utils.PrettyTable()
181+ pretty_table.add_column(16, label="ID")
182+ pretty_table.add_column(30, label="Name")
183+ pretty_table.add_column(19, label="Last Accessed (UTC)")
184+ # 1 TB takes 13 characters to display: len(str(2**40)) == 13
185+ pretty_table.add_column(14, label="Size", just="r")
186+ pretty_table.add_column(10, label="Hits", just="r")
187+
188+ print pretty_table.make_header()
189+
190+ for image in images:
191+ print pretty_table.make_row(
192+ image['id'],
193+ image['name'],
194+ image['last_accessed'],
195+ image['size'],
196+ image['hits'])
197+
198+
199+@catch_error('show invalid cache images')
200+def cache_invalid(options, args):
201+ """
202+%(prog)s cache-invalid [options]
203+
204+List current invalid cache images"""
205+ client = get_client(options)
206+ images = client.get_invalid_cached_images()
207+ if not images:
208+ print "No invalid cached images."
209+ return SUCCESS
210+
211+ print "Found %d invalid cached images..." % len(images)
212+
213+ pretty_table = utils.PrettyTable()
214+ pretty_table.add_column(16, label="ID")
215+ pretty_table.add_column(30, label="Name")
216+ pretty_table.add_column(30, label="Error")
217+ pretty_table.add_column(19, label="Last Modified (UTC)")
218+ # 1 TB takes 13 characters to display: len(str(2**40)) == 13
219+ pretty_table.add_column(14, label="Size", just="r")
220+ pretty_table.add_column(14, label="Expected Size", just="r")
221+ pretty_table.add_column(7, label="% Done", just="r")
222+
223+ print pretty_table.make_header()
224+
225+ for image in images:
226+ print pretty_table.make_row(
227+ image['id'],
228+ image['name'],
229+ image['error'],
230+ image['last_accessed'],
231+ image['size'],
232+ image['expected_size'],
233+ get_percent_done(image))
234+
235+
236+@catch_error('show incomplete cache images')
237+def cache_incomplete(options, args):
238+ """
239+%(prog)s cache-incomplete [options]
240+
241+List images currently being fetched"""
242+ client = get_client(options)
243+ images = client.get_incomplete_cached_images()
244+ if not images:
245+ print "No incomplete cached images."
246+ return SUCCESS
247+
248+ print "Found %d incomplete cached images..." % len(images)
249+
250+ pretty_table = utils.PrettyTable()
251+ pretty_table.add_column(16, label="ID")
252+ pretty_table.add_column(30, label="Name")
253+ pretty_table.add_column(19, label="Last Modified (UTC)")
254+ # 1 TB takes 13 characters to display: len(str(2**40)) == 13
255+ pretty_table.add_column(14, label="Size", just="r")
256+ pretty_table.add_column(14, label="Expected Size", just="r")
257+ pretty_table.add_column(7, label="% Done", just="r")
258+
259+ print pretty_table.make_header()
260+
261+ for image in images:
262+ print pretty_table.make_row(
263+ image['id'],
264+ image['name'],
265+ image['last_modified'],
266+ image['size'],
267+ image['expected_size'],
268+ get_percent_done(image))
269+
270+
271+@catch_error('purge the specified cached image')
272+def cache_purge(options, args):
273+ """
274+%(prog)s cache-purge [options]
275+
276+Purges an image from the cache"""
277+ try:
278+ image_id = args.pop()
279+ except IndexError:
280+ print "Please specify the ID of the image you wish to purge "
281+ print "from the cache as the first argument"
282+ return FAILURE
283+
284+ if not options.force and \
285+ not user_confirm("Purge cached image %s?" % (image_id,), default=False):
286+ print 'Not purging cached image %s' % (image_id,)
287+ return FAILURE
288+
289+ client = get_client(options)
290+ client.purge_cached_image(image_id)
291+
292+ if options.verbose:
293+ print "done"
294+
295+
296+@catch_error('clear all cached images')
297+def cache_clear(options, args):
298+ """
299+%(prog)s cache-clear [options]
300+
301+Removes all images from the cache"""
302+ if not options.force and \
303+ not user_confirm("Clear all cached images?", default=False):
304+ print 'Not purging any cached images.'
305+ return FAILURE
306+
307+ client = get_client(options)
308+ num_purged = client.clear_cached_images()
309+
310+ if options.verbose:
311+ print "Purged %(num_purged)s cached images" % locals()
312+
313+
314+@catch_error('reap invalid images')
315+def cache_reap_invalid(options, args):
316+ """
317+%(prog)s cache-reap-invalid [options]
318+
319+Reaps any invalid images that were left for
320+debugging purposes"""
321+ if not options.force and \
322+ not user_confirm("Reap all invalid cached images?", default=False):
323+ print 'Not reaping any invalid cached images.'
324+ return FAILURE
325+
326+ client = get_client(options)
327+ num_reaped = client.reap_invalid_cached_images()
328+
329+ if options.verbose:
330+ print "Reaped %(num_reaped)s invalid cached images" % locals()
331+
332+
333+@catch_error('reap stalled images')
334+def cache_reap_stalled(options, args):
335+ """
336+%(prog)s cache-reap-stalled [options]
337+
338+Reaps any stalled incomplete images"""
339+ if not options.force and \
340+ not user_confirm("Reap all stalled cached images?", default=False):
341+ print 'Not reaping any stalled cached images.'
342+ return FAILURE
343+
344+ client = get_client(options)
345+ num_reaped = client.reap_stalled_cached_images()
346+
347+ if options.verbose:
348+ print "Reaped %(num_reaped)s stalled cached images" % locals()
349+
350+
351+@catch_error('prefetch the specified cached image')
352+def cache_prefetch(options, args):
353+ """
354+%(prog)s cache-prefetch [options]
355+
356+Pre-fetch an image or list of images into the cache"""
357+ image_ids = args
358+ if not image_ids:
359+ print "Please specify the ID or a list of image IDs of the images "\
360+ "you wish to "
361+ print "prefetch from the cache as the first argument"
362+ return FAILURE
363+
364+ client = get_client(options)
365+ for image_id in image_ids:
366+ if options.verbose:
367+ print "Prefetching image '%s'" % image_id
368+
369+ try:
370+ client.prefetch_cache_image(image_id)
371+ except exception.NotFound:
372+ print "No image with ID %s was found" % image_id
373+ continue
374+
375+ if options.verbose:
376+ print "done"
377+
378+
379+@catch_error('show prefetching images')
380+def cache_prefetching(options, args):
381+ """
382+%(prog)s cache-prefetching [options]
383+
384+List images that are being prefetched"""
385+ client = get_client(options)
386+ images = client.get_prefetching_cache_images()
387+ if not images:
388+ print "No images being prefetched."
389+ return SUCCESS
390+
391+ print "Found %d images being prefetched..." % len(images)
392+
393+ pretty_table = utils.PrettyTable()
394+ pretty_table.add_column(16, label="ID")
395+ pretty_table.add_column(30, label="Name")
396+ pretty_table.add_column(19, label="Last Accessed (UTC)")
397+ pretty_table.add_column(10, label="Status", just="r")
398+
399+ print pretty_table.make_header()
400+
401+ for image in images:
402+ print pretty_table.make_row(
403+ image['id'],
404+ image['name'],
405+ image['last_accessed'],
406+ image['status'])
407+
408+
409 def get_client(options):
410 """
411 Returns a new client object to a Glance server
412 specified by the --host and --port options
413 supplied to the CLI
414 """
415- return client.Client(host=options.host,
416+ return glance_client.Client(host=options.host,
417 port=options.port)
418
419
420@@ -500,28 +771,23 @@
421
422 :param parser: The option parser
423 """
424- COMMANDS = {'help': print_help,
425- 'add': image_add,
426- 'update': image_update,
427- 'delete': image_delete,
428- 'index': images_index,
429- 'details': images_detailed,
430- 'show': image_show,
431- 'clear': images_clear}
432-
433 if not cli_args:
434 cli_args.append('-h') # Show options in usage output...
435
436 (options, args) = parser.parse_args(cli_args)
437
438+ # HACK(sirp): Make the parser available to the print_help method
439+ # print_help is a command, so it only accepts (options, args); we could
440+ # one-off have it take (parser, options, args), however, for now, I think
441+ # this little hack will suffice
442+ options.__parser = parser
443+
444 if not args:
445 parser.print_usage()
446 sys.exit(0)
447- else:
448- command_name = args.pop(0)
449- if command_name not in COMMANDS.keys():
450- sys.exit("Unknown command: %s" % command_name)
451- command = COMMANDS[command_name]
452+
453+ command_name = args.pop(0)
454+ command = lookup_command(parser, command_name)
455
456 return (options, command, args)
457
458@@ -530,28 +796,55 @@
459 """
460 Print help specific to a command
461 """
462- COMMANDS = {'add': image_add,
463- 'update': image_update,
464- 'delete': image_delete,
465- 'index': images_index,
466- 'details': images_detailed,
467- 'show': image_show,
468- 'clear': images_clear}
469-
470 if len(args) != 1:
471 sys.exit("Please specify a command")
472
473- command = args.pop()
474- if command not in COMMANDS.keys():
475+ parser = options.__parser
476+ command_name = args.pop()
477+ command = lookup_command(parser, command_name)
478+
479+ print command.__doc__ % {'prog': os.path.basename(sys.argv[0])}
480+
481+
482+def lookup_command(parser, command_name):
483+ BASE_COMMANDS = {'help': print_help}
484+
485+ IMAGE_COMMANDS = {
486+ 'add': image_add,
487+ 'update': image_update,
488+ 'delete': image_delete,
489+ 'index': images_index,
490+ 'details': images_detailed,
491+ 'show': image_show,
492+ 'clear': images_clear}
493+
494+ CACHE_COMMANDS = {
495+ 'cache-index': cache_index,
496+ 'cache-invalid': cache_invalid,
497+ 'cache-incomplete': cache_incomplete,
498+ 'cache-prefetching': cache_prefetching,
499+ 'cache-prefetch': cache_prefetch,
500+ 'cache-purge': cache_purge,
501+ 'cache-clear': cache_clear,
502+ 'cache-reap-invalid': cache_reap_invalid,
503+ 'cache-reap-stalled': cache_reap_stalled}
504+
505+ commands = {}
506+ for command_set in (BASE_COMMANDS, IMAGE_COMMANDS, CACHE_COMMANDS):
507+ commands.update(command_set)
508+
509+ try:
510+ command = commands[command_name]
511+ except KeyError:
512 parser.print_usage()
513- if args:
514- sys.exit("Unknown command: %s" % command)
515+ sys.exit("Unknown command: %s" % command_name)
516
517- print COMMANDS[command].__doc__ % {'prog': os.path.basename(sys.argv[0])}
518+ return command
519
520
521 def user_confirm(prompt, default=False):
522- """Yes/No question dialog with user.
523+ """
524+ Yes/No question dialog with user.
525
526 :param prompt: question/statement to present to user (string)
527 :param default: boolean value to return if empty string
528@@ -595,6 +888,27 @@
529
530 clear Removes all images and metadata from Glance
531
532+
533+Cache Commands:
534+
535+ cache-index List all images currently cached
536+
537+ cache-invalid List current invalid cache images
538+
539+ cache-incomplete List images currently being fetched
540+
541+ cache-prefetching List images that are being prefetched
542+
543+ cache-prefetch Pre-fetch an image or list of images into the cache
544+
545+ cache-purge Purges an image from the cache
546+
547+ cache-clear Removes all images from the cache
548+
549+ cache-reap-invalid Reaps any invalid images that were left for
550+ debugging purposes
551+
552+ cache-reap-stalled Reaps any stalled incomplete images
553 """
554
555 oparser = optparse.OptionParser(version='%%prog %s'
556
557=== added file 'bin/glance-cache-prefetcher'
558--- bin/glance-cache-prefetcher 1970-01-01 00:00:00 +0000
559+++ bin/glance-cache-prefetcher 2011-07-26 10:13:35 +0000
560@@ -0,0 +1,65 @@
561+#!/usr/bin/env python
562+# vim: tabstop=4 shiftwidth=4 softtabstop=4
563+
564+# Copyright 2010 United States Government as represented by the
565+# Administrator of the National Aeronautics and Space Administration.
566+# Copyright 2011 OpenStack LLC.
567+# All Rights Reserved.
568+#
569+# Licensed under the Apache License, Version 2.0 (the "License"); you may
570+# not use this file except in compliance with the License. You may obtain
571+# a copy of the License at
572+#
573+# http://www.apache.org/licenses/LICENSE-2.0
574+#
575+# Unless required by applicable law or agreed to in writing, software
576+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
577+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
578+# License for the specific language governing permissions and limitations
579+# under the License.
580+
581+"""
582+Glance Image Cache Pre-fetcher
583+
584+This is meant to be run as a periodic task from cron.
585+"""
586+
587+import optparse
588+import os
589+import sys
590+
591+# If ../glance/__init__.py exists, add ../ to Python search path, so that
592+# it will override what happens to be installed in /usr/(local/)lib/python...
593+possible_topdir = os.path.normpath(os.path.join(os.path.abspath(sys.argv[0]),
594+ os.pardir,
595+ os.pardir))
596+if os.path.exists(os.path.join(possible_topdir, 'glance', '__init__.py')):
597+ sys.path.insert(0, possible_topdir)
598+
599+from glance import version
600+from glance.common import config
601+from glance.common import wsgi
602+
603+
604+def create_options(parser):
605+ """
606+ Sets up the CLI and config-file options that may be
607+ parsed and program commands.
608+
609+ :param parser: The option parser
610+ """
611+ config.add_common_options(parser)
612+ config.add_log_options(parser)
613+
614+
615+if __name__ == '__main__':
616+ oparser = optparse.OptionParser(version='%%prog %s'
617+ % version.version_string())
618+ create_options(oparser)
619+ (options, args) = config.parse_options(oparser)
620+
621+ try:
622+ conf, app = config.load_paste_app('glance-prefetcher', options, args)
623+ app.run()
624+ except RuntimeError, e:
625+ sys.exit("ERROR: %s" % e)
626
627=== added file 'bin/glance-cache-pruner'
628--- bin/glance-cache-pruner 1970-01-01 00:00:00 +0000
629+++ bin/glance-cache-pruner 2011-07-26 10:13:35 +0000
630@@ -0,0 +1,65 @@
631+#!/usr/bin/env python
632+# vim: tabstop=4 shiftwidth=4 softtabstop=4
633+
634+# Copyright 2010 United States Government as represented by the
635+# Administrator of the National Aeronautics and Space Administration.
636+# Copyright 2011 OpenStack LLC.
637+# All Rights Reserved.
638+#
639+# Licensed under the Apache License, Version 2.0 (the "License"); you may
640+# not use this file except in compliance with the License. You may obtain
641+# a copy of the License at
642+#
643+# http://www.apache.org/licenses/LICENSE-2.0
644+#
645+# Unless required by applicable law or agreed to in writing, software
646+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
647+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
648+# License for the specific language governing permissions and limitations
649+# under the License.
650+
651+"""
652+Glance Image Cache Pruner
653+
654+This is meant to be run as a periodic task, perhaps every half-hour.
655+"""
656+
657+import optparse
658+import os
659+import sys
660+
661+# If ../glance/__init__.py exists, add ../ to Python search path, so that
662+# it will override what happens to be installed in /usr/(local/)lib/python...
663+possible_topdir = os.path.normpath(os.path.join(os.path.abspath(sys.argv[0]),
664+ os.pardir,
665+ os.pardir))
666+if os.path.exists(os.path.join(possible_topdir, 'glance', '__init__.py')):
667+ sys.path.insert(0, possible_topdir)
668+
669+from glance import version
670+from glance.common import config
671+from glance.common import wsgi
672+
673+
674+def create_options(parser):
675+ """
676+ Sets up the CLI and config-file options that may be
677+ parsed and program commands.
678+
679+ :param parser: The option parser
680+ """
681+ config.add_common_options(parser)
682+ config.add_log_options(parser)
683+
684+
685+if __name__ == '__main__':
686+ oparser = optparse.OptionParser(version='%%prog %s'
687+ % version.version_string())
688+ create_options(oparser)
689+ (options, args) = config.parse_options(oparser)
690+
691+ try:
692+ conf, app = config.load_paste_app('glance-pruner', options, args)
693+ app.run()
694+ except RuntimeError, e:
695+ sys.exit("ERROR: %s" % e)
696
697=== added file 'bin/glance-cache-reaper'
698--- bin/glance-cache-reaper 1970-01-01 00:00:00 +0000
699+++ bin/glance-cache-reaper 2011-07-26 10:13:35 +0000
700@@ -0,0 +1,73 @@
701+#!/usr/bin/env python
702+# vim: tabstop=4 shiftwidth=4 softtabstop=4
703+
704+# Copyright 2010 United States Government as represented by the
705+# Administrator of the National Aeronautics and Space Administration.
706+# Copyright 2011 OpenStack LLC.
707+# All Rights Reserved.
708+#
709+# Licensed under the Apache License, Version 2.0 (the "License"); you may
710+# not use this file except in compliance with the License. You may obtain
711+# a copy of the License at
712+#
713+# http://www.apache.org/licenses/LICENSE-2.0
714+#
715+# Unless required by applicable law or agreed to in writing, software
716+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
717+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
718+# License for the specific language governing permissions and limitations
719+# under the License.
720+
721+"""
722+Glance Image Cache Invalid Cache Entry and Stalled Image Reaper
723+
724+This is meant to be run as a periodic task from cron.
725+
726+If something goes wrong while we're caching an image (for example the fetch
727+times out, or an exception is raised), we create an 'invalid' entry. These
728+entires are left around for debugging purposes. However, after some period of
729+time, we want to cleans these up, aka reap them.
730+
731+Also, if an incomplete image hangs around past the image_cache_stall_timeout
732+period, we automatically sweep it up.
733+"""
734+
735+import optparse
736+import os
737+import sys
738+
739+# If ../glance/__init__.py exists, add ../ to Python search path, so that
740+# it will override what happens to be installed in /usr/(local/)lib/python...
741+possible_topdir = os.path.normpath(os.path.join(os.path.abspath(sys.argv[0]),
742+ os.pardir,
743+ os.pardir))
744+if os.path.exists(os.path.join(possible_topdir, 'glance', '__init__.py')):
745+ sys.path.insert(0, possible_topdir)
746+
747+from glance import version
748+from glance.common import config
749+from glance.common import wsgi
750+
751+
752+def create_options(parser):
753+ """
754+ Sets up the CLI and config-file options that may be
755+ parsed and program commands.
756+
757+ :param parser: The option parser
758+ """
759+ config.add_common_options(parser)
760+ config.add_log_options(parser)
761+
762+
763+if __name__ == '__main__':
764+ oparser = optparse.OptionParser(version='%%prog %s'
765+ % version.version_string())
766+ create_options(oparser)
767+ (options, args) = config.parse_options(oparser)
768+
769+ try:
770+ conf, app = config.load_paste_app('glance-reaper', options, args)
771+ app.run()
772+ except RuntimeError, e:
773+ sys.exit("ERROR: %s" % e)
774
775=== modified file 'bin/glance-control'
776--- bin/glance-control 2011-05-09 18:55:46 +0000
777+++ bin/glance-control 2011-07-26 10:13:35 +0000
778@@ -44,15 +44,16 @@
779
780 ALL_COMMANDS = ['start', 'stop', 'shutdown', 'restart',
781 'reload', 'force-reload']
782-ALL_SERVERS = ['glance-api', 'glance-registry']
783-GRACEFUL_SHUTDOWN_SERVERS = ['glance-api', 'glance-registry']
784+ALL_SERVERS = ['glance-api', 'glance-registry', 'glance-scrubber']
785+GRACEFUL_SHUTDOWN_SERVERS = ['glance-api', 'glance-registry',
786+ 'glance-scrubber']
787 MAX_DESCRIPTORS = 32768
788 MAX_MEMORY = (1024 * 1024 * 1024) * 2 # 2 GB
789 USAGE = """%prog [options] <SERVER> <COMMAND> [CONFPATH]
790
791 Where <SERVER> is one of:
792
793- all, api, registry
794+ all, api, registry, scrubber
795
796 And command is one of:
797
798
799=== added file 'bin/glance-scrubber'
800--- bin/glance-scrubber 1970-01-01 00:00:00 +0000
801+++ bin/glance-scrubber 2011-07-26 10:13:35 +0000
802@@ -0,0 +1,80 @@
803+#!/usr/bin/env python
804+# vim: tabstop=4 shiftwidth=4 softtabstop=4
805+
806+# Copyright 2011 OpenStack LLC.
807+# All Rights Reserved.
808+#
809+# Licensed under the Apache License, Version 2.0 (the "License"); you may
810+# not use this file except in compliance with the License. You may obtain
811+# a copy of the License at
812+#
813+# http://www.apache.org/licenses/LICENSE-2.0
814+#
815+# Unless required by applicable law or agreed to in writing, software
816+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
817+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
818+# License for the specific language governing permissions and limitations
819+# under the License.
820+
821+"""
822+Glance Scrub Service
823+"""
824+
825+import optparse
826+import os
827+import sys
828+
829+# If ../glance/__init__.py exists, add ../ to Python search path, so that
830+# it will override what happens to be installed in /usr/(local/)lib/python...
831+possible_topdir = os.path.normpath(os.path.join(os.path.abspath(sys.argv[0]),
832+ os.pardir,
833+ os.pardir))
834+if os.path.exists(os.path.join(possible_topdir, 'glance', '__init__.py')):
835+ sys.path.insert(0, possible_topdir)
836+
837+from glance import version
838+from glance.common import config
839+from glance.store import scrubber
840+
841+
842+def create_options(parser):
843+ """
844+ Sets up the CLI and config-file options that may be
845+ parsed and program commands.
846+
847+ :param parser: The option parser
848+ """
849+ config.add_common_options(parser)
850+ config.add_log_options(parser)
851+ parser.add_option("-D", "--daemon", default=False, dest="daemon",
852+ action="store_true",
853+ help="Run as a long-running process. When not "
854+ "specified (the default) run the scrub "
855+ "operation once and then exits. When specified "
856+ "do not exit and run scrub on wakeup_time "
857+ "interval as specified in the config file.")
858+
859+
860+if __name__ == '__main__':
861+ oparser = optparse.OptionParser(version='%%prog %s'
862+ % version.version_string())
863+ create_options(oparser)
864+ (options, args) = config.parse_options(oparser)
865+
866+ try:
867+ conf, app = config.load_paste_app('glance-scrubber', options, args)
868+ daemon = options.get('daemon') or \
869+ config.get_option(conf, 'daemon', type='bool',
870+ default=False)
871+
872+ if daemon:
873+ wakeup_time = int(conf.get('wakeup_time', 300))
874+ server = scrubber.Daemon(wakeup_time)
875+ server.start(app)
876+ server.wait()
877+ else:
878+ import eventlet
879+ pool = eventlet.greenpool.GreenPool(1000)
880+ scrubber = app.run(pool)
881+ except RuntimeError, e:
882+ sys.exit("ERROR: %s" % e)
883
884=== modified file 'etc/glance-api.conf'
885--- etc/glance-api.conf 2011-05-11 23:03:51 +0000
886+++ etc/glance-api.conf 2011-07-26 10:13:35 +0000
887@@ -51,8 +51,26 @@
888 # Do we create the container if it does not exist?
889 swift_store_create_container_on_put = False
890
891+# ============ Image Cache Options ========================
892+
893+# Directory that the Image Cache writes data to
894+# Make sure this is also set in glance-pruner.conf
895+image_cache_datadir = /var/lib/glance/image-cache/
896+
897+# Number of seconds after which we should consider an incomplete image to be
898+# stalled and eligible for reaping
899+image_cache_stall_timeout = 86400
900+
901+# ============ Delayed Delete Options =============================
902+
903+# Turn on/off delayed delete
904+delayed_delete = False
905+
906 [pipeline:glance-api]
907-pipeline = versionnegotiation apiv1app
908+pipeline = versionnegotiation context apiv1app
909+
910+# To enable Image Cache Management API replace pipeline with below:
911+# pipeline = versionnegotiation imagecache apiv1app
912
913 [pipeline:versions]
914 pipeline = versionsapp
915@@ -65,3 +83,9 @@
916
917 [filter:versionnegotiation]
918 paste.filter_factory = glance.api.middleware.version_negotiation:filter_factory
919+
920+[filter:imagecache]
921+paste.filter_factory = glance.api.middleware.image_cache:filter_factory
922+
923+[filter:context]
924+paste.filter_factory = glance.common.context:filter_factory
925
926=== added file 'etc/glance-prefetcher.conf'
927--- etc/glance-prefetcher.conf 1970-01-01 00:00:00 +0000
928+++ etc/glance-prefetcher.conf 2011-07-26 10:13:35 +0000
929@@ -0,0 +1,21 @@
930+[DEFAULT]
931+# Show more verbose log output (sets INFO log level output)
932+verbose = True
933+
934+# Show debugging output in logs (sets DEBUG log level output)
935+debug = False
936+
937+log_file = /var/log/glance/prefetcher.log
938+
939+# Directory that the Image Cache writes data to
940+# Make sure this is also set in glance-api.conf
941+image_cache_datadir = /var/lib/glance/image-cache/
942+
943+# Address to find the registry server
944+registry_host = 0.0.0.0
945+
946+# Port the registry server is listening on
947+registry_port = 9191
948+
949+[app:glance-prefetcher]
950+paste.app_factory = glance.image_cache.prefetcher:app_factory
951
952=== added file 'etc/glance-pruner.conf'
953--- etc/glance-pruner.conf 1970-01-01 00:00:00 +0000
954+++ etc/glance-pruner.conf 2011-07-26 10:13:35 +0000
955@@ -0,0 +1,28 @@
956+[DEFAULT]
957+# Show more verbose log output (sets INFO log level output)
958+verbose = True
959+
960+# Show debugging output in logs (sets DEBUG log level output)
961+debug = False
962+
963+log_file = /var/log/glance/pruner.log
964+
965+image_cache_max_size_bytes = 1073741824
966+
967+# Percentage of the cache that should be freed (in addition to the overage)
968+# when the cache is pruned
969+#
970+# A percentage of 0% means we prune only as many files as needed to remain
971+# under the cache's max_size. This is space efficient but will lead to
972+# constant pruning as the size bounces just-above and just-below the max_size.
973+#
974+# To mitigate this 'thrashing', you can specify an additional amount of the
975+# cache that should be tossed out on each prune.
976+image_cache_percent_extra_to_free = 0.20
977+
978+# Directory that the Image Cache writes data to
979+# Make sure this is also set in glance-api.conf
980+image_cache_datadir = /var/lib/glance/image-cache/
981+
982+[app:glance-pruner]
983+paste.app_factory = glance.image_cache.pruner:app_factory
984
985=== added file 'etc/glance-reaper.conf'
986--- etc/glance-reaper.conf 1970-01-01 00:00:00 +0000
987+++ etc/glance-reaper.conf 2011-07-26 10:13:35 +0000
988@@ -0,0 +1,29 @@
989+[DEFAULT]
990+# Show more verbose log output (sets INFO log level output)
991+verbose = True
992+
993+# Show debugging output in logs (sets DEBUG log level output)
994+debug = False
995+
996+log_file = /var/log/glance/reaper.log
997+
998+# Directory that the Image Cache writes data to
999+# Make sure this is also set in glance-api.conf
1000+image_cache_datadir = /var/lib/glance/image-cache/
1001+
1002+# image_cache_invalid_entry_grace_period - seconds
1003+#
1004+# If an exception is raised as we're writing to the cache, the cache-entry is
1005+# deemed invalid and moved to <image_cache_datadir>/invalid so that it can be
1006+# inspected for debugging purposes.
1007+#
1008+# This is number of seconds to leave these invalid images around before they
1009+# are elibible to be reaped.
1010+image_cache_invalid_entry_grace_period = 3600
1011+
1012+# Number of seconds after which we should consider an incomplete image to be
1013+# stalled and eligible for reaping
1014+image_cache_stall_timeout = 86400
1015+
1016+[app:glance-reaper]
1017+paste.app_factory = glance.image_cache.reaper:app_factory
1018
1019=== modified file 'etc/glance-registry.conf'
1020--- etc/glance-registry.conf 2011-05-09 18:55:46 +0000
1021+++ etc/glance-registry.conf 2011-07-26 10:13:35 +0000
1022@@ -29,5 +29,11 @@
1023 # before MySQL can drop the connection.
1024 sql_idle_timeout = 3600
1025
1026-[app:glance-registry]
1027+[pipeline:glance-registry]
1028+pipeline = context registryapp
1029+
1030+[app:registryapp]
1031 paste.app_factory = glance.registry.server:app_factory
1032+
1033+[filter:context]
1034+paste.filter_factory = glance.common.context:filter_factory
1035
1036=== added file 'etc/glance-scrubber.conf'
1037--- etc/glance-scrubber.conf 1970-01-01 00:00:00 +0000
1038+++ etc/glance-scrubber.conf 2011-07-26 10:13:35 +0000
1039@@ -0,0 +1,36 @@
1040+[DEFAULT]
1041+# Show more verbose log output (sets INFO log level output)
1042+verbose = True
1043+
1044+# Show debugging output in logs (sets DEBUG log level output)
1045+debug = False
1046+
1047+# Log to this file. Make sure you do not set the same log
1048+# file for both the API and registry servers!
1049+log_file = /var/log/glance/scrubber.log
1050+
1051+# Delayed delete time in seconds
1052+scrub_time = 43200
1053+
1054+# Should we run our own loop or rely on cron/scheduler to run us
1055+daemon = False
1056+
1057+# Loop time between checking the db for new items to schedule for delete
1058+wakeup_time = 300
1059+
1060+# SQLAlchemy connection string for the reference implementation
1061+# registry server. Any valid SQLAlchemy connection string is fine.
1062+# See: http://www.sqlalchemy.org/docs/05/reference/sqlalchemy/connections.html#sqlalchemy.create_engine
1063+sql_connection = sqlite:///glance.sqlite
1064+
1065+# Period in seconds after which SQLAlchemy should reestablish its connection
1066+# to the database.
1067+#
1068+# MySQL uses a default `wait_timeout` of 8 hours, after which it will drop
1069+# idle connections. This can result in 'MySQL Gone Away' exceptions. If you
1070+# notice this, you can lower this value to ensure that SQLAlchemy reconnects
1071+# before MySQL can drop the connection.
1072+sql_idle_timeout = 3600
1073+
1074+[app:glance-scrubber]
1075+paste.app_factory = glance.store.scrubber:app_factory
1076
1077=== modified file 'glance/api/__init__.py'
1078--- glance/api/__init__.py 2011-05-05 23:12:21 +0000
1079+++ glance/api/__init__.py 2011-07-26 10:13:35 +0000
1080@@ -14,3 +14,50 @@
1081 # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
1082 # License for the specific language governing permissions and limitations
1083 # under the License.
1084+import logging
1085+import webob.exc
1086+
1087+from glance import registry
1088+from glance.common import exception
1089+
1090+
1091+logger = logging.getLogger('glance.api')
1092+
1093+
1094+class BaseController(object):
1095+ def get_image_meta_or_404(self, request, id):
1096+ """
1097+ Grabs the image metadata for an image with a supplied
1098+ identifier or raises an HTTPNotFound (404) response
1099+
1100+ :param request: The WSGI/Webob Request object
1101+ :param id: The opaque image identifier
1102+
1103+ :raises HTTPNotFound if image does not exist
1104+ """
1105+ context = request.context
1106+ try:
1107+ return registry.get_image_metadata(self.options, context, id)
1108+ except exception.NotFound:
1109+ msg = "Image with identifier %s not found" % id
1110+ logger.debug(msg)
1111+ raise webob.exc.HTTPNotFound(
1112+ msg, request=request, content_type='text/plain')
1113+ except exception.NotAuthorized:
1114+ msg = "Unauthorized image access"
1115+ logger.debug(msg)
1116+ raise webob.exc.HTTPForbidden(msg, request=request,
1117+ content_type='text/plain')
1118+
1119+ def get_active_image_meta_or_404(self, request, id):
1120+ """
1121+ Same as get_image_meta_or_404 except that it will raise a 404 if the
1122+ image isn't 'active'.
1123+ """
1124+ image = self.get_image_meta_or_404(request, id)
1125+ if image['status'] != 'active':
1126+ msg = "Image %s is not active" % id
1127+ logger.debug(msg)
1128+ raise webob.exc.HTTPNotFound(
1129+ msg, request=request, content_type='text/plain')
1130+ return image
1131
1132=== added file 'glance/api/cached_images.py'
1133--- glance/api/cached_images.py 1970-01-01 00:00:00 +0000
1134+++ glance/api/cached_images.py 2011-07-26 10:13:35 +0000
1135@@ -0,0 +1,105 @@
1136+# vim: tabstop=4 shiftwidth=4 softtabstop=4
1137+
1138+# Copyright 2011 OpenStack LLC.
1139+# All Rights Reserved.
1140+#
1141+# Licensed under the Apache License, Version 2.0 (the "License"); you may
1142+# not use this file except in compliance with the License. You may obtain
1143+# a copy of the License at
1144+#
1145+# http://www.apache.org/licenses/LICENSE-2.0
1146+#
1147+# Unless required by applicable law or agreed to in writing, software
1148+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
1149+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
1150+# License for the specific language governing permissions and limitations
1151+# under the License.
1152+
1153+"""
1154+Controller for Image Cache Management API
1155+"""
1156+
1157+import httplib
1158+import json
1159+
1160+import webob.dec
1161+import webob.exc
1162+
1163+from glance.common import exception
1164+from glance.common import wsgi
1165+from glance import api
1166+from glance import image_cache
1167+from glance import registry
1168+
1169+
1170+class Controller(api.BaseController):
1171+ """
1172+ A controller that produces information on the Glance API versions.
1173+ """
1174+
1175+ def __init__(self, options):
1176+ self.options = options
1177+ self.cache = image_cache.ImageCache(self.options)
1178+
1179+ def index(self, req):
1180+ status = req.str_params.get('status')
1181+ if status == 'invalid':
1182+ entries = list(self.cache.invalid_entries())
1183+ elif status == 'incomplete':
1184+ entries = list(self.cache.incomplete_entries())
1185+ elif status == 'prefetching':
1186+ entries = list(self.cache.prefetch_entries())
1187+ else:
1188+ entries = list(self.cache.entries())
1189+
1190+ return dict(cached_images=entries)
1191+
1192+ def delete(self, req, id):
1193+ self.cache.purge(id)
1194+
1195+ def delete_collection(self, req):
1196+ """
1197+ DELETE /cached_images - Clear all active cached images
1198+ DELETE /cached_images?status=invalid - Reap invalid cached images
1199+ DELETE /cached_images?status=incomplete - Reap stalled cached images
1200+ """
1201+ status = req.str_params.get('status')
1202+ if status == 'invalid':
1203+ num_reaped = self.cache.reap_invalid()
1204+ return dict(num_reaped=num_reaped)
1205+ elif status == 'incomplete':
1206+ num_reaped = self.cache.reap_stalled()
1207+ return dict(num_reaped=num_reaped)
1208+ else:
1209+ num_purged = self.cache.clear()
1210+ return dict(num_purged=num_purged)
1211+
1212+ def update(self, req, id):
1213+ """PUT /cached_images/1 is used to prefetch an image into the cache"""
1214+ image_meta = self.get_active_image_meta_or_404(req, id)
1215+ try:
1216+ self.cache.queue_prefetch(image_meta)
1217+ except exception.Invalid, e:
1218+ raise webob.exc.HTTPBadRequest(explanation=str(e))
1219+
1220+
1221+class CachedImageDeserializer(wsgi.JSONRequestDeserializer):
1222+ pass
1223+
1224+
1225+class CachedImageSerializer(wsgi.JSONResponseSerializer):
1226+ pass
1227+
1228+
1229+def create_resource(options):
1230+ """Cached Images resource factory method"""
1231+ deserializer = CachedImageDeserializer()
1232+ serializer = CachedImageSerializer()
1233+ return wsgi.Resource(Controller(options), deserializer, serializer)
1234+
1235+
1236+def app_factory(global_conf, **local_conf):
1237+ """paste.deploy app factory for creating Cached Images apps"""
1238+ conf = global_conf.copy()
1239+ conf.update(local_conf)
1240+ return Controller(conf)
1241
1242=== added file 'glance/api/middleware/image_cache.py'
1243--- glance/api/middleware/image_cache.py 1970-01-01 00:00:00 +0000
1244+++ glance/api/middleware/image_cache.py 2011-07-26 10:13:35 +0000
1245@@ -0,0 +1,57 @@
1246+# vim: tabstop=4 shiftwidth=4 softtabstop=4
1247+
1248+# Copyright 2011 OpenStack LLC.
1249+# All Rights Reserved.
1250+#
1251+# Licensed under the Apache License, Version 2.0 (the "License"); you may
1252+# not use this file except in compliance with the License. You may obtain
1253+# a copy of the License at
1254+#
1255+# http://www.apache.org/licenses/LICENSE-2.0
1256+#
1257+# Unless required by applicable law or agreed to in writing, software
1258+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
1259+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
1260+# License for the specific language governing permissions and limitations
1261+# under the License.
1262+
1263+"""
1264+Image Cache Management API
1265+"""
1266+
1267+import logging
1268+
1269+from glance.api import cached_images
1270+from glance.common import wsgi
1271+
1272+logger = logging.getLogger('glance.api.middleware.image_cache')
1273+
1274+
1275+class ImageCacheFilter(wsgi.Middleware):
1276+ def __init__(self, app, options):
1277+ super(ImageCacheFilter, self).__init__(app)
1278+
1279+ map = app.map
1280+ resource = cached_images.create_resource(options)
1281+ map.resource("cached_image", "cached_images",
1282+ controller=resource,
1283+ collection={'reap_invalid': 'POST',
1284+ 'reap_stalled': 'POST'})
1285+
1286+ map.connect("/cached_images",
1287+ controller=resource,
1288+ action="delete_collection",
1289+ conditions=dict(method=["DELETE"]))
1290+
1291+
1292+def filter_factory(global_conf, **local_conf):
1293+ """
1294+ Factory method for paste.deploy
1295+ """
1296+ conf = global_conf.copy()
1297+ conf.update(local_conf)
1298+
1299+ def filter(app):
1300+ return ImageCacheFilter(app, conf)
1301+
1302+ return filter
1303
1304=== modified file 'glance/api/middleware/version_negotiation.py'
1305--- glance/api/middleware/version_negotiation.py 2011-05-13 22:28:51 +0000
1306+++ glance/api/middleware/version_negotiation.py 2011-07-26 10:13:35 +0000
1307@@ -74,7 +74,7 @@
1308 req.environ['api.minor_version'])
1309 return self.versions_app
1310
1311- accept = req.headers['Accept']
1312+ accept = str(req.accept)
1313 if accept.startswith('application/vnd.openstack.images-'):
1314 token_loc = len('application/vnd.openstack.images-')
1315 accept_version = accept[token_loc:]
1316
1317=== modified file 'glance/api/v1/__init__.py'
1318--- glance/api/v1/__init__.py 2011-06-27 18:05:29 +0000
1319+++ glance/api/v1/__init__.py 2011-07-26 10:13:35 +0000
1320@@ -34,7 +34,7 @@
1321 mapper = routes.Mapper()
1322 resource = images.create_resource(options)
1323 mapper.resource("image", "images", controller=resource,
1324- collection={'detail': 'GET'})
1325+ collection={'detail': 'GET'})
1326 mapper.connect("/", controller=resource, action="index")
1327 mapper.connect("/images/{id}", controller=resource, action="meta",
1328 conditions=dict(method=["HEAD"]))
1329
1330=== modified file 'glance/api/v1/images.py'
1331--- glance/api/v1/images.py 2011-06-28 14:03:10 +0000
1332+++ glance/api/v1/images.py 2011-07-26 10:13:35 +0000
1333@@ -27,12 +27,15 @@
1334 import webob
1335 from webob.exc import (HTTPNotFound,
1336 HTTPConflict,
1337- HTTPBadRequest)
1338+ HTTPBadRequest,
1339+ HTTPForbidden)
1340
1341+from glance import api
1342+from glance import image_cache
1343 from glance.common import exception
1344 from glance.common import wsgi
1345 from glance.store import (get_from_backend,
1346- delete_from_backend,
1347+ schedule_delete_from_backend,
1348 get_store_from_location,
1349 get_backend_class,
1350 UnsupportedBackend)
1351@@ -43,13 +46,12 @@
1352 logger = logging.getLogger('glance.api.v1.images')
1353
1354 SUPPORTED_FILTERS = ['name', 'status', 'container_format', 'disk_format',
1355- 'size_min', 'size_max']
1356+ 'size_min', 'size_max', 'is_public']
1357
1358 SUPPORTED_PARAMS = ('limit', 'marker', 'sort_key', 'sort_dir')
1359
1360
1361-class Controller(object):
1362-
1363+class Controller(api.BaseController):
1364 """
1365 WSGI controller for images resource in Glance v1 API
1366
1367@@ -95,7 +97,12 @@
1368 ]}
1369 """
1370 params = self._get_query_params(req)
1371- images = registry.get_images_list(self.options, **params)
1372+ try:
1373+ images = registry.get_images_list(self.options, req.context,
1374+ **params)
1375+ except exception.Invalid, e:
1376+ raise HTTPBadRequest(explanation=str(e))
1377+
1378 return dict(images=images)
1379
1380 def detail(self, req):
1381@@ -121,7 +128,11 @@
1382 ]}
1383 """
1384 params = self._get_query_params(req)
1385- images = registry.get_images_detail(self.options, **params)
1386+ try:
1387+ images = registry.get_images_detail(self.options, req.context,
1388+ **params)
1389+ except exception.Invalid, e:
1390+ raise HTTPBadRequest(explanation=str(e))
1391 return dict(images=images)
1392
1393 def _get_query_params(self, req):
1394@@ -176,18 +187,63 @@
1395
1396 :raises HTTPNotFound if image is not available to user
1397 """
1398- image = self.get_image_meta_or_404(req, id)
1399-
1400- def image_iterator():
1401- chunks = get_from_backend(image['location'],
1402- expected_size=image['size'],
1403- options=self.options)
1404-
1405- for chunk in chunks:
1406- yield chunk
1407+ image = self.get_active_image_meta_or_404(req, id)
1408+
1409+ def get_from_store(image):
1410+ """Called if caching disabled"""
1411+ return get_from_backend(image['location'],
1412+ expected_size=image['size'],
1413+ options=self.options)
1414+
1415+ def get_from_cache(image, cache):
1416+ """Called if cache hit"""
1417+ with cache.open(image, "rb") as cache_file:
1418+ chunks = utils.chunkiter(cache_file)
1419+ for chunk in chunks:
1420+ yield chunk
1421+
1422+ def get_from_store_tee_into_cache(image, cache):
1423+ """Called if cache miss"""
1424+ with cache.open(image, "wb") as cache_file:
1425+ chunks = get_from_store(image)
1426+ for chunk in chunks:
1427+ cache_file.write(chunk)
1428+ yield chunk
1429+
1430+ cache = image_cache.ImageCache(self.options)
1431+ if cache.enabled:
1432+ if cache.hit(id):
1433+ # hit
1434+ logger.debug("image cache HIT, retrieving image '%s'"
1435+ " from cache", id)
1436+ image_iterator = get_from_cache(image, cache)
1437+ else:
1438+ # miss
1439+ logger.debug("image cache MISS, retrieving image '%s'"
1440+ " from store and tee'ing into cache", id)
1441+
1442+ # We only want to tee-into the cache if we're not currently
1443+ # prefetching an image
1444+ image_id = image['id']
1445+ if cache.is_image_currently_prefetching(image_id):
1446+ image_iterator = get_from_store(image)
1447+ else:
1448+ # NOTE(sirp): If we're about to download and cache an
1449+ # image which is currently in the prefetch queue, just
1450+ # delete the queue items since we're caching it anyway
1451+ if cache.is_image_queued_for_prefetch(image_id):
1452+ cache.delete_queued_prefetch_image(image_id)
1453+
1454+ image_iterator = get_from_store_tee_into_cache(
1455+ image, cache)
1456+ else:
1457+ # disabled
1458+ logger.debug("image cache DISABLED, retrieving image '%s'"
1459+ " from store", id)
1460+ image_iterator = get_from_store(image)
1461
1462 return {
1463- 'image_iterator': image_iterator(),
1464+ 'image_iterator': image_iterator,
1465 'image_meta': image,
1466 }
1467
1468@@ -219,6 +275,7 @@
1469
1470 try:
1471 image_meta = registry.add_image_metadata(self.options,
1472+ req.context,
1473 image_meta)
1474 return image_meta
1475 except exception.Duplicate:
1476@@ -231,6 +288,11 @@
1477 for line in msg.split('\n'):
1478 logger.error(line)
1479 raise HTTPBadRequest(msg, request=req, content_type="text/plain")
1480+ except exception.NotAuthorized:
1481+ msg = "Not authorized to reserve image."
1482+ logger.error(msg)
1483+ raise HTTPForbidden(msg, request=req,
1484+ content_type="text/plain")
1485
1486 def _upload(self, req, image_meta):
1487 """
1488@@ -259,12 +321,12 @@
1489 store = self.get_store_or_400(req, store_name)
1490
1491 image_id = image_meta['id']
1492- logger.debug("Setting image %s to status 'saving'" % image_id)
1493- registry.update_image_metadata(self.options, image_id,
1494+ logger.debug("Setting image %s to status 'saving'", image_id)
1495+ registry.update_image_metadata(self.options, req.context, image_id,
1496 {'status': 'saving'})
1497 try:
1498 logger.debug("Uploading image data for image %(image_id)s "
1499- "to %(store_name)s store" % locals())
1500+ "to %(store_name)s store", locals())
1501 location, size, checksum = store.add(image_meta['id'],
1502 req.body_file,
1503 self.options)
1504@@ -286,8 +348,9 @@
1505 # from the backend store
1506 logger.debug("Updating image %(image_id)s data. "
1507 "Checksum set to %(checksum)s, size set "
1508- "to %(size)d" % locals())
1509- registry.update_image_metadata(self.options, image_id,
1510+ "to %(size)d", locals())
1511+ registry.update_image_metadata(self.options, req.context,
1512+ image_id,
1513 {'checksum': checksum,
1514 'size': size})
1515
1516@@ -299,6 +362,13 @@
1517 self._safe_kill(req, image_id)
1518 raise HTTPConflict(msg, request=req)
1519
1520+ except exception.NotAuthorized, e:
1521+ msg = ("Unauthorized upload attempt: %s") % str(e)
1522+ logger.error(msg)
1523+ self._safe_kill(req, image_id)
1524+ raise HTTPForbidden(msg, request=req,
1525+ content_type='text/plain')
1526+
1527 except Exception, e:
1528 msg = ("Error uploading image: %s") % str(e)
1529 logger.error(msg)
1530@@ -318,6 +388,7 @@
1531 image_meta['location'] = location
1532 image_meta['status'] = 'active'
1533 return registry.update_image_metadata(self.options,
1534+ req.context,
1535 image_id,
1536 image_meta)
1537
1538@@ -329,6 +400,7 @@
1539 :param image_id: Opaque image identifier
1540 """
1541 registry.update_image_metadata(self.options,
1542+ req.context,
1543 image_id,
1544 {'status': 'killed'})
1545
1546@@ -397,6 +469,12 @@
1547 and the request body is not application/octet-stream
1548 image data.
1549 """
1550+ if req.context.read_only:
1551+ msg = "Read-only access"
1552+ logger.debug(msg)
1553+ raise HTTPForbidden(msg, request=req,
1554+ content_type="text/plain")
1555+
1556 image_meta = self._reserve(req, image_meta)
1557 image_id = image_meta['id']
1558
1559@@ -418,6 +496,12 @@
1560
1561 :retval Returns the updated image information as a mapping
1562 """
1563+ if req.context.read_only:
1564+ msg = "Read-only access"
1565+ logger.debug(msg)
1566+ raise HTTPForbidden(msg, request=req,
1567+ content_type="text/plain")
1568+
1569 orig_image_meta = self.get_image_meta_or_404(req, id)
1570 orig_status = orig_image_meta['status']
1571
1572@@ -425,8 +509,9 @@
1573 raise HTTPConflict("Cannot upload to an unqueued image")
1574
1575 try:
1576- image_meta = registry.update_image_metadata(self.options, id,
1577- image_meta, True)
1578+ image_meta = registry.update_image_metadata(self.options,
1579+ req.context, id,
1580+ image_meta, True)
1581 if image_data is not None:
1582 image_meta = self._upload_and_activate(req, image_meta)
1583 except exception.Invalid, e:
1584@@ -450,6 +535,12 @@
1585 :raises HttpNotAuthorized if image or any chunk is not
1586 deleteable by the requesting user
1587 """
1588+ if req.context.read_only:
1589+ msg = "Read-only access"
1590+ logger.debug(msg)
1591+ raise HTTPForbidden(msg, request=req,
1592+ content_type="text/plain")
1593+
1594 image = self.get_image_meta_or_404(req, id)
1595
1596 # The image's location field may be None in the case
1597@@ -457,32 +548,9 @@
1598 # to delete the image if the backend doesn't yet store it.
1599 # See https://bugs.launchpad.net/glance/+bug/747799
1600 if image['location']:
1601- try:
1602- delete_from_backend(image['location'])
1603- except (UnsupportedBackend, exception.NotFound):
1604- msg = "Failed to delete image from store (%s). " + \
1605- "Continuing with deletion from registry."
1606- logger.error(msg % (image['location'],))
1607-
1608- registry.delete_image_metadata(self.options, id)
1609-
1610- def get_image_meta_or_404(self, request, id):
1611- """
1612- Grabs the image metadata for an image with a supplied
1613- identifier or raises an HTTPNotFound (404) response
1614-
1615- :param request: The WSGI/Webob Request object
1616- :param id: The opaque image identifier
1617-
1618- :raises HTTPNotFound if image does not exist
1619- """
1620- try:
1621- return registry.get_image_metadata(self.options, id)
1622- except exception.NotFound:
1623- msg = "Image with identifier %s not found" % id
1624- logger.debug(msg)
1625- raise HTTPNotFound(msg, request=request,
1626- content_type='text/plain')
1627+ schedule_delete_from_backend(image['location'], self.options,
1628+ req.context, id)
1629+ registry.delete_image_metadata(self.options, req.context, id)
1630
1631 def get_store_or_400(self, request, store_name):
1632 """
1633
1634=== modified file 'glance/client.py'
1635--- glance/client.py 2011-06-28 23:20:51 +0000
1636+++ glance/client.py 2011-07-26 10:13:35 +0000
1637@@ -35,7 +35,8 @@
1638
1639 DEFAULT_PORT = 9292
1640
1641- def __init__(self, host, port=None, use_ssl=False, doc_root="/v1"):
1642+ def __init__(self, host, port=None, use_ssl=False, doc_root="/v1",
1643+ auth_tok=None):
1644 """
1645 Creates a new client to a Glance API service.
1646
1647@@ -43,11 +44,11 @@
1648 :param port: The port where Glance resides (defaults to 9292)
1649 :param use_ssl: Should we use HTTPS? (defaults to False)
1650 :param doc_root: Prefix for all URLs we request from host
1651+ :param auth_tok: The auth token to pass to the server
1652 """
1653-
1654 port = port or self.DEFAULT_PORT
1655 self.doc_root = doc_root
1656- super(Client, self).__init__(host, port, use_ssl)
1657+ super(Client, self).__init__(host, port, use_ssl, auth_tok)
1658
1659 def do_request(self, method, action, body=None, headers=None, params=None):
1660 action = "%s/%s" % (self.doc_root, action.lstrip("/"))
1661@@ -81,7 +82,6 @@
1662 :param sort_key: results will be ordered by this image attribute
1663 :param sort_dir: direction in which to to order results (asc, desc)
1664 """
1665-
1666 params = self._extract_params(kwargs, v1_images.SUPPORTED_PARAMS)
1667 res = self.do_request("GET", "/images/detail", params=params)
1668 data = json.loads(res.read())['images']
1669@@ -126,7 +126,6 @@
1670
1671 :retval The newly-stored image's metadata.
1672 """
1673-
1674 headers = utils.image_meta_to_http_headers(image_meta or {})
1675
1676 if image_data:
1677@@ -165,5 +164,120 @@
1678 self.do_request("DELETE", "/images/%s" % image_id)
1679 return True
1680
1681+ def get_cached_images(self, **kwargs):
1682+ """
1683+ Returns a list of images stored in the image cache.
1684+
1685+ :param filters: dictionary of attributes by which the resulting
1686+ collection of images should be filtered
1687+ :param marker: id after which to start the page of images
1688+ :param limit: maximum number of items to return
1689+ :param sort_key: results will be ordered by this image attribute
1690+ :param sort_dir: direction in which to to order results (asc, desc)
1691+ """
1692+ params = self._extract_params(kwargs, v1_images.SUPPORTED_PARAMS)
1693+ res = self.do_request("GET", "/cached_images", params=params)
1694+ data = json.loads(res.read())['cached_images']
1695+ return data
1696+
1697+ def get_invalid_cached_images(self, **kwargs):
1698+ """
1699+ Returns a list of invalid images stored in the image cache.
1700+
1701+ :param filters: dictionary of attributes by which the resulting
1702+ collection of images should be filtered
1703+ :param marker: id after which to start the page of images
1704+ :param limit: maximum number of items to return
1705+ :param sort_key: results will be ordered by this image attribute
1706+ :param sort_dir: direction in which to to order results (asc, desc)
1707+ """
1708+ params = self._extract_params(kwargs, v1_images.SUPPORTED_PARAMS)
1709+ params['status'] = 'invalid'
1710+ res = self.do_request("GET", "/cached_images", params=params)
1711+ data = json.loads(res.read())['cached_images']
1712+ return data
1713+
1714+ def get_incomplete_cached_images(self, **kwargs):
1715+ """
1716+ Returns a list of incomplete images being fetched into cache
1717+
1718+ :param filters: dictionary of attributes by which the resulting
1719+ collection of images should be filtered
1720+ :param marker: id after which to start the page of images
1721+ :param limit: maximum number of items to return
1722+ :param sort_key: results will be ordered by this image attribute
1723+ :param sort_dir: direction in which to to order results (asc, desc)
1724+ """
1725+ params = self._extract_params(kwargs, v1_images.SUPPORTED_PARAMS)
1726+ params['status'] = 'incomplete'
1727+ res = self.do_request("GET", "/cached_images", params=params)
1728+ data = json.loads(res.read())['cached_images']
1729+ return data
1730+
1731+ def purge_cached_image(self, image_id):
1732+ """
1733+ Delete a specified image from the cache
1734+ """
1735+ self.do_request("DELETE", "/cached_images/%s" % image_id)
1736+ return True
1737+
1738+ def clear_cached_images(self):
1739+ """
1740+ Clear all cached images
1741+ """
1742+ res = self.do_request("DELETE", "/cached_images")
1743+ data = json.loads(res.read())
1744+ num_purged = data['num_purged']
1745+ return num_purged
1746+
1747+ def reap_invalid_cached_images(self, **kwargs):
1748+ """
1749+ Reaps any invalid cached images
1750+ """
1751+ params = self._extract_params(kwargs, v1_images.SUPPORTED_PARAMS)
1752+ params['status'] = 'invalid'
1753+ res = self.do_request("DELETE", "/cached_images", params=params)
1754+ data = json.loads(res.read())
1755+ num_reaped = data['num_reaped']
1756+ return num_reaped
1757+
1758+ def reap_stalled_cached_images(self, **kwargs):
1759+ """
1760+ Reaps any stalled cached images
1761+ """
1762+ params = self._extract_params(kwargs, v1_images.SUPPORTED_PARAMS)
1763+ params['status'] = 'incomplete'
1764+ res = self.do_request("DELETE", "/cached_images", params=params)
1765+ data = json.loads(res.read())
1766+ num_reaped = data['num_reaped']
1767+ return num_reaped
1768+
1769+ def prefetch_cache_image(self, image_id):
1770+ """
1771+ Pre-fetch a specified image from the cache
1772+ """
1773+ res = self.do_request("HEAD", "/images/%s" % image_id)
1774+ image = utils.get_image_meta_from_headers(res)
1775+ self.do_request("PUT", "/cached_images/%s" % image_id)
1776+ return True
1777+
1778+ def get_prefetching_cache_images(self, **kwargs):
1779+ """
1780+ Returns a list of images which are actively being prefetched or are
1781+ queued to be prefetched in the future.
1782+
1783+ :param filters: dictionary of attributes by which the resulting
1784+ collection of images should be filtered
1785+ :param marker: id after which to start the page of images
1786+ :param limit: maximum number of items to return
1787+ :param sort_key: results will be ordered by this image attribute
1788+ :param sort_dir: direction in which to to order results (asc, desc)
1789+ """
1790+ params = self._extract_params(kwargs, v1_images.SUPPORTED_PARAMS)
1791+ params['status'] = 'prefetching'
1792+ res = self.do_request("GET", "/cached_images", params=params)
1793+ data = json.loads(res.read())['cached_images']
1794+ return data
1795+
1796
1797 Client = V1Client
1798
1799=== modified file 'glance/common/client.py'
1800--- glance/common/client.py 2011-06-29 13:07:16 +0000
1801+++ glance/common/client.py 2011-07-26 10:13:35 +0000
1802@@ -41,17 +41,19 @@
1803
1804 CHUNKSIZE = 65536
1805
1806- def __init__(self, host, port, use_ssl):
1807+ def __init__(self, host, port, use_ssl, auth_tok):
1808 """
1809 Creates a new client to some service.
1810
1811 :param host: The host where service resides
1812 :param port: The port where service resides
1813 :param use_ssl: Should we use HTTPS?
1814+ :param auth_tok: The auth token to pass to the server
1815 """
1816 self.host = host
1817 self.port = port
1818 self.use_ssl = use_ssl
1819+ self.auth_tok = auth_tok
1820 self.connection = None
1821
1822 def get_connection_type(self):
1823@@ -99,6 +101,8 @@
1824 try:
1825 connection_type = self.get_connection_type()
1826 headers = headers or {}
1827+ if 'x-auth-token' not in headers and self.auth_tok:
1828+ headers['x-auth-token'] = self.auth_tok
1829 c = connection_type(self.host, self.port)
1830
1831 # Do a simple request or a chunked request, depending
1832
1833=== added file 'glance/common/context.py'
1834--- glance/common/context.py 1970-01-01 00:00:00 +0000
1835+++ glance/common/context.py 2011-07-26 10:13:35 +0000
1836@@ -0,0 +1,97 @@
1837+# vim: tabstop=4 shiftwidth=4 softtabstop=4
1838+
1839+# Copyright 2011 OpenStack LLC.
1840+# All Rights Reserved.
1841+#
1842+# Licensed under the Apache License, Version 2.0 (the "License"); you may
1843+# not use this file except in compliance with the License. You may obtain
1844+# a copy of the License at
1845+#
1846+# http://www.apache.org/licenses/LICENSE-2.0
1847+#
1848+# Unless required by applicable law or agreed to in writing, software
1849+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
1850+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
1851+# License for the specific language governing permissions and limitations
1852+# under the License.
1853+
1854+from glance.common import utils
1855+from glance.common import wsgi
1856+
1857+
1858+class RequestContext(object):
1859+ """
1860+ Stores information about the security context under which the user
1861+ accesses the system, as well as additional request information.
1862+ """
1863+
1864+ def __init__(self, auth_tok=None, user=None, tenant=None, is_admin=False,
1865+ read_only=False, show_deleted=False):
1866+ self.auth_tok = auth_tok
1867+ self.user = user
1868+ self.tenant = tenant
1869+ self.is_admin = is_admin
1870+ self.read_only = read_only
1871+ self.show_deleted = show_deleted
1872+
1873+ def is_image_visible(self, image):
1874+ """Return True if the image is visible in this context."""
1875+ # Is admin == image visible
1876+ if self.is_admin:
1877+ return True
1878+
1879+ # No owner == image visible
1880+ if image.owner is None:
1881+ return True
1882+
1883+ # Image is_public == image visible
1884+ if image.is_public:
1885+ return True
1886+
1887+ # Private image
1888+ return self.owner is not None and self.owner == image.owner
1889+
1890+ @property
1891+ def owner(self):
1892+ """Return the owner to correlate with an image."""
1893+ return self.tenant
1894+
1895+
1896+class ContextMiddleware(wsgi.Middleware):
1897+ def __init__(self, app, options):
1898+ self.options = options
1899+ super(ContextMiddleware, self).__init__(app)
1900+
1901+ def make_context(self, *args, **kwargs):
1902+ """
1903+ Create a context with the given arguments.
1904+ """
1905+
1906+ # Determine the context class to use
1907+ ctxcls = RequestContext
1908+ if 'context_class' in self.options:
1909+ ctxcls = utils.import_class(self.options['context_class'])
1910+
1911+ return ctxcls(*args, **kwargs)
1912+
1913+ def process_request(self, req):
1914+ """
1915+ Extract any authentication information in the request and
1916+ construct an appropriate context from it.
1917+ """
1918+ # Use the default empty context, with admin turned on for
1919+ # backwards compatibility
1920+ req.context = self.make_context(is_admin=True)
1921+
1922+
1923+def filter_factory(global_conf, **local_conf):
1924+ """
1925+ Factory method for paste.deploy
1926+ """
1927+ conf = global_conf.copy()
1928+ conf.update(local_conf)
1929+
1930+ def filter(app):
1931+ return ContextMiddleware(app, conf)
1932+
1933+ return filter
1934
1935=== modified file 'glance/common/exception.py'
1936--- glance/common/exception.py 2011-06-26 20:43:41 +0000
1937+++ glance/common/exception.py 2011-07-26 10:13:35 +0000
1938@@ -54,6 +54,24 @@
1939 pass
1940
1941
1942+class UnknownScheme(Error):
1943+
1944+ msg = "Unknown scheme '%s' found in URI"
1945+
1946+ def __init__(self, scheme):
1947+ msg = self.__class__.msg % scheme
1948+ super(UnknownScheme, self).__init__(msg)
1949+
1950+
1951+class BadStoreUri(Error):
1952+
1953+ msg = "The Store URI %s was malformed. Reason: %s"
1954+
1955+ def __init__(self, uri, reason):
1956+ msg = self.__class__.msg % (uri, reason)
1957+ super(BadStoreUri, self).__init__(msg)
1958+
1959+
1960 class Duplicate(Error):
1961 pass
1962
1963
1964=== added directory 'glance/image_cache'
1965=== added file 'glance/image_cache/__init__.py'
1966--- glance/image_cache/__init__.py 1970-01-01 00:00:00 +0000
1967+++ glance/image_cache/__init__.py 2011-07-26 10:13:35 +0000
1968@@ -0,0 +1,451 @@
1969+# vim: tabstop=4 shiftwidth=4 softtabstop=4
1970+
1971+# Copyright 2011 OpenStack LLC.
1972+# All Rights Reserved.
1973+#
1974+# Licensed under the Apache License, Version 2.0 (the "License"); you may
1975+# not use this file except in compliance with the License. You may obtain
1976+# a copy of the License at
1977+#
1978+# http://www.apache.org/licenses/LICENSE-2.0
1979+#
1980+# Unless required by applicable law or agreed to in writing, software
1981+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
1982+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
1983+# License for the specific language governing permissions and limitations
1984+# under the License.
1985+
1986+"""
1987+LRU Cache for Image Data
1988+"""
1989+from contextlib import contextmanager
1990+import datetime
1991+import itertools
1992+import logging
1993+import os
1994+import sys
1995+import time
1996+
1997+from glance.common import config
1998+from glance.common import exception
1999+from glance import utils
2000+
2001+logger = logging.getLogger('glance.image_cache')
2002+
2003+
2004+class ImageCache(object):
2005+ """Provides an LRU cache for image data.
2006+
2007+ Data is cached on READ not on WRITE; meaning if the cache is enabled, we
2008+ attempt to read from the cache first, if we don't find the data, we begin
2009+ streaming the data from the 'store' while simultaneously tee'ing the data
2010+ into the cache. Subsequent reads will generate cache HITs for this image.
2011+
2012+ Assumptions
2013+ ===========
2014+
2015+ 1. Cache data directory exists on a filesytem that updates atime on
2016+ reads ('noatime' should NOT be set)
2017+
2018+ 2. Cache data directory exists on a filesystem that supports xattrs.
2019+ This is optional, but highly recommended since it allows us to
2020+ present ops with useful information pertaining to the cache, like
2021+ human readable filenames and statistics.
2022+
2023+ 3. `glance-prune` is scheduled to run as a periodic job via cron. This
2024+ is needed to run the LRU prune strategy to keep the cache size
2025+ within the limits set by the config file.
2026+
2027+
2028+ Cache Directory Notes
2029+ =====================
2030+
2031+ The image cache data directory contains the main cache path, where the
2032+ active cache entries and subdirectories for handling partial downloads
2033+ and errored-out cache images.
2034+
2035+ The layout looks like:
2036+
2037+ image-cache/
2038+ entry1
2039+ entry2
2040+ ...
2041+ incomplete/
2042+ invalid/
2043+ prefetch/
2044+ prefetching/
2045+ """
2046+ def __init__(self, options):
2047+ self.options = options
2048+ self._make_cache_directory_if_needed()
2049+
2050+ def _make_cache_directory_if_needed(self):
2051+ """Creates main cache directory along with incomplete subdirectory"""
2052+ if not self.enabled:
2053+ return
2054+
2055+ # NOTE(sirp): making the incomplete_path will have the effect of
2056+ # creating the main cache path directory as well
2057+ paths = [self.incomplete_path, self.invalid_path, self.prefetch_path,
2058+ self.prefetching_path]
2059+
2060+ for path in paths:
2061+ if os.path.exists(path):
2062+ continue
2063+ logger.info("image cache directory doesn't exist, creating '%s'",
2064+ path)
2065+ os.makedirs(path)
2066+
2067+ @property
2068+ def enabled(self):
2069+ return self.options.get('image_cache_enabled', False)
2070+
2071+ @property
2072+ def path(self):
2073+ """This is the base path for the image cache"""
2074+ datadir = self.options['image_cache_datadir']
2075+ return datadir
2076+
2077+ @property
2078+ def incomplete_path(self):
2079+ """This provides a temporary place to write our cache entries so that
2080+ we we're not storing incomplete objects in the cache directly.
2081+
2082+ When the file is finished writing to, it is moved from the incomplete
2083+ path back out into the main cache directory.
2084+
2085+ The incomplete_path is a subdirectory of the main cache path to ensure
2086+ that they both reside on the same filesystem and thus making moves
2087+ cheap.
2088+ """
2089+ return os.path.join(self.path, 'incomplete')
2090+
2091+ @property
2092+ def invalid_path(self):
2093+ """Place to move corrupted images
2094+
2095+ If an exception is raised while we're writing an image to the
2096+ incomplete_path, we move the incomplete image to here.
2097+ """
2098+ return os.path.join(self.path, 'invalid')
2099+
2100+ @property
2101+ def prefetch_path(self):
2102+ """This contains a list of image ids that should be pre-fetched into
2103+ the cache
2104+ """
2105+ return os.path.join(self.path, 'prefetch')
2106+
2107+ @property
2108+ def prefetching_path(self):
2109+ """This contains image ids that currently being prefetched"""
2110+ return os.path.join(self.path, 'prefetching')
2111+
2112+ def path_for_image(self, image_id):
2113+ """This crafts an absolute path to a specific entry"""
2114+ return os.path.join(self.path, str(image_id))
2115+
2116+ def incomplete_path_for_image(self, image_id):
2117+ """This crafts an absolute path to a specific entry in the incomplete
2118+ directory
2119+ """
2120+ return os.path.join(self.incomplete_path, str(image_id))
2121+
2122+ def invalid_path_for_image(self, image_id):
2123+ """This crafts an absolute path to a specific entry in the invalid
2124+ directory
2125+ """
2126+ return os.path.join(self.invalid_path, str(image_id))
2127+
2128+ @contextmanager
2129+ def open(self, image_meta, mode="rb"):
2130+ """Open a cache image for reading or writing.
2131+
2132+ We have two possible scenarios:
2133+
2134+ 1. READ: we should attempt to read the file from the cache's
2135+ main directory
2136+
2137+ 2. WRITE: we should write to a file under the cache's incomplete
2138+ directory, and when it's finished, move it out the main cache
2139+ directory.
2140+ """
2141+ if mode == 'wb':
2142+ with self._open_write(image_meta, mode) as cache_file:
2143+ yield cache_file
2144+ elif mode == 'rb':
2145+ with self._open_read(image_meta, mode) as cache_file:
2146+ yield cache_file
2147+ else:
2148+ # NOTE(sirp): `rw` and `a' modes are not supported since image
2149+ # data is immutable, we `wb` it once, then `rb` multiple times.
2150+ raise Exception("mode '%s' not supported" % mode)
2151+
2152+ @contextmanager
2153+ def _open_write(self, image_meta, mode):
2154+ image_id = image_meta['id']
2155+ incomplete_path = self.incomplete_path_for_image(image_id)
2156+
2157+ def set_xattr(key, value):
2158+ utils.set_xattr(incomplete_path, key, value)
2159+
2160+ def commit():
2161+ set_xattr('image_name', image_meta['name'])
2162+ set_xattr('hits', 0)
2163+
2164+ final_path = self.path_for_image(image_id)
2165+ logger.debug("fetch finished, commiting by moving "
2166+ "'%(incomplete_path)s' to '%(final_path)s'",
2167+ dict(incomplete_path=incomplete_path,
2168+ final_path=final_path))
2169+ os.rename(incomplete_path, final_path)
2170+
2171+ def rollback(e):
2172+ set_xattr('image_name', image_meta['name'])
2173+ set_xattr('error', str(e))
2174+
2175+ invalid_path = self.invalid_path_for_image(image_id)
2176+ logger.debug("fetch errored, rolling back by moving "
2177+ "'%(incomplete_path)s' to '%(invalid_path)s'",
2178+ dict(incomplete_path=incomplete_path,
2179+ invalid_path=invalid_path))
2180+ os.rename(incomplete_path, invalid_path)
2181+
2182+ try:
2183+ with open(incomplete_path, mode) as cache_file:
2184+ set_xattr('expected_size', image_meta['size'])
2185+ yield cache_file
2186+ except Exception as e:
2187+ rollback(e)
2188+ raise
2189+ else:
2190+ commit()
2191+
2192+ @contextmanager
2193+ def _open_read(self, image_meta, mode):
2194+ image_id = image_meta['id']
2195+ path = self.path_for_image(image_id)
2196+ with open(path, mode) as cache_file:
2197+ yield cache_file
2198+
2199+ utils.inc_xattr(path, 'hits') # bump the hit count
2200+
2201+ def hit(self, image_id):
2202+ return os.path.exists(self.path_for_image(image_id))
2203+
2204+ @staticmethod
2205+ def _delete_file(path):
2206+ if os.path.exists(path):
2207+ logger.debug("deleting image cache file '%s'", path)
2208+ os.unlink(path)
2209+ else:
2210+ logger.warn("image cache file '%s' doesn't exist, unable to"
2211+ " delete", path)
2212+
2213+ def purge(self, image_id):
2214+ path = self.path_for_image(image_id)
2215+ self._delete_file(path)
2216+
2217+ def clear(self):
2218+ purged = 0
2219+ for path in self.get_all_regular_files(self.path):
2220+ self._delete_file(path)
2221+ purged += 1
2222+ return purged
2223+
2224+ def is_image_currently_being_written(self, image_id):
2225+ """Returns true if we're currently downloading an image"""
2226+ incomplete_path = self.incomplete_path_for_image(image_id)
2227+ return os.path.exists(incomplete_path)
2228+
2229+ def is_currently_prefetching_any_images(self):
2230+ """True if we are currently prefetching an image.
2231+
2232+ We only allow one prefetch to occur at a time.
2233+ """
2234+ return len(os.listdir(self.prefetching_path)) > 0
2235+
2236+ def is_image_queued_for_prefetch(self, image_id):
2237+ prefetch_path = os.path.join(self.prefetch_path, str(image_id))
2238+ return os.path.exists(prefetch_path)
2239+
2240+ def is_image_currently_prefetching(self, image_id):
2241+ prefetching_path = os.path.join(self.prefetching_path, str(image_id))
2242+ return os.path.exists(prefetching_path)
2243+
2244+ def queue_prefetch(self, image_meta):
2245+ """This adds a image to be prefetched to the queue directory.
2246+
2247+ If the image already exists in the queue directory or the
2248+ prefetching directory, we ignore it.
2249+ """
2250+ image_id = image_meta['id']
2251+
2252+ if self.hit(image_id):
2253+ msg = "Skipping prefetch, image '%s' already cached" % image_id
2254+ logger.warn(msg)
2255+ raise exception.Invalid(msg)
2256+
2257+ if self.is_image_currently_prefetching(image_id):
2258+ msg = "Skipping prefetch, already prefetching image '%s'"\
2259+ % image_id
2260+ logger.warn(msg)
2261+ raise exception.Invalid(msg)
2262+
2263+ if self.is_image_queued_for_prefetch(image_id):
2264+ msg = "Skipping prefetch, image '%s' already queued for"\
2265+ " prefetching" % image_id
2266+ logger.warn(msg)
2267+ raise exception.Invalid(msg)
2268+
2269+ prefetch_path = os.path.join(self.prefetch_path, str(image_id))
2270+
2271+ # Touch the file to add it to the queue
2272+ with open(prefetch_path, "w") as f:
2273+ pass
2274+
2275+ utils.set_xattr(prefetch_path, 'image_name', image_meta['name'])
2276+
2277+ def delete_queued_prefetch_image(self, image_id):
2278+ prefetch_path = os.path.join(self.prefetch_path, str(image_id))
2279+ self._delete_file(prefetch_path)
2280+
2281+ def delete_prefetching_image(self, image_id):
2282+ prefetching_path = os.path.join(self.prefetching_path, str(image_id))
2283+ self._delete_file(prefetching_path)
2284+
2285+ def pop_prefetch_item(self):
2286+ """This returns the next prefetch job.
2287+
2288+ The prefetch directory is treated like a FIFO; so we sort by modified
2289+ time and pick the oldest.
2290+ """
2291+ items = []
2292+ for path in self.get_all_regular_files(self.prefetch_path):
2293+ mtime = os.path.getmtime(path)
2294+ items.append((mtime, path))
2295+
2296+ if not items:
2297+ raise IndexError
2298+
2299+ # Sort oldest files to the end of the list
2300+ items.sort(reverse=True)
2301+
2302+ mtime, path = items.pop()
2303+ image_id = os.path.basename(path)
2304+ return image_id
2305+
2306+ def do_prefetch(self, image_id):
2307+ """This moves the file from the prefetch queue path to the in-progress
2308+ prefetching path (so we don't try to prefetch something twice).
2309+ """
2310+ prefetch_path = os.path.join(self.prefetch_path, str(image_id))
2311+ prefetching_path = os.path.join(self.prefetching_path, str(image_id))
2312+ os.rename(prefetch_path, prefetching_path)
2313+
2314+ @staticmethod
2315+ def get_all_regular_files(basepath):
2316+ for fname in os.listdir(basepath):
2317+ path = os.path.join(basepath, fname)
2318+ if os.path.isfile(path):
2319+ yield path
2320+
2321+ def _base_entries(self, basepath):
2322+ def iso8601_from_timestamp(timestamp):
2323+ return datetime.datetime.utcfromtimestamp(timestamp)\
2324+ .isoformat()
2325+
2326+ for path in self.get_all_regular_files(basepath):
2327+ filename = os.path.basename(path)
2328+ try:
2329+ image_id = int(filename)
2330+ except ValueError, TypeError:
2331+ continue
2332+
2333+ entry = {}
2334+ entry['id'] = image_id
2335+ entry['path'] = path
2336+ entry['name'] = utils.get_xattr(path, 'image_name',
2337+ default='UNKNOWN')
2338+
2339+ mtime = os.path.getmtime(path)
2340+ entry['last_modified'] = iso8601_from_timestamp(mtime)
2341+
2342+ atime = os.path.getatime(path)
2343+ entry['last_accessed'] = iso8601_from_timestamp(atime)
2344+
2345+ entry['size'] = os.path.getsize(path)
2346+
2347+ entry['expected_size'] = utils.get_xattr(
2348+ path, 'expected_size', default='UNKNOWN')
2349+
2350+ yield entry
2351+
2352+ def invalid_entries(self):
2353+ """Cache info for invalid cached images"""
2354+ for entry in self._base_entries(self.invalid_path):
2355+ path = entry['path']
2356+ entry['error'] = utils.get_xattr(path, 'error', default='UNKNOWN')
2357+ yield entry
2358+
2359+ def incomplete_entries(self):
2360+ """Cache info for invalid cached images"""
2361+ for entry in self._base_entries(self.incomplete_path):
2362+ yield entry
2363+
2364+ def prefetch_entries(self):
2365+ """Cache info for both queued and in-progress prefetch jobs"""
2366+ both_entries = itertools.chain(
2367+ self._base_entries(self.prefetch_path),
2368+ self._base_entries(self.prefetching_path))
2369+
2370+ for entry in both_entries:
2371+ path = entry['path']
2372+ entry['status'] = 'in-progress' if 'prefetching' in path\
2373+ else 'queued'
2374+ yield entry
2375+
2376+ def entries(self):
2377+ """Cache info for currently cached images"""
2378+ for entry in self._base_entries(self.path):
2379+ path = entry['path']
2380+ entry['hits'] = utils.get_xattr(path, 'hits', default='UNKNOWN')
2381+ yield entry
2382+
2383+ def _reap_old_files(self, dirpath, entry_type, grace=None):
2384+ """
2385+ """
2386+ now = time.time()
2387+ reaped = 0
2388+ for path in self.get_all_regular_files(dirpath):
2389+ mtime = os.path.getmtime(path)
2390+ age = now - mtime
2391+ if not grace:
2392+ logger.debug("No grace period, reaping '%(path)s'"
2393+ " immediately", locals())
2394+ self._delete_file(path)
2395+ reaped += 1
2396+ elif age > grace:
2397+ logger.debug("Cache entry '%(path)s' exceeds grace period, "
2398+ "(%(age)i s > %(grace)i s)", locals())
2399+ self._delete_file(path)
2400+ reaped += 1
2401+
2402+ logger.info("Reaped %(reaped)s %(entry_type)s cache entries",
2403+ locals())
2404+ return reaped
2405+
2406+ def reap_invalid(self, grace=None):
2407+ """Remove any invalid cache entries
2408+
2409+ :param grace: Number of seconds to keep an invalid entry around for
2410+ debugging purposes. If None, then delete immediately.
2411+ """
2412+ return self._reap_old_files(self.invalid_path, 'invalid', grace=grace)
2413+
2414+ def reap_stalled(self):
2415+ """Remove any stalled cache entries"""
2416+ stall_timeout = int(self.options.get('image_cache_stall_timeout',
2417+ 86400))
2418+ return self._reap_old_files(self.incomplete_path, 'stalled',
2419+ grace=stall_timeout)
2420
2421=== added file 'glance/image_cache/prefetcher.py'
2422--- glance/image_cache/prefetcher.py 1970-01-01 00:00:00 +0000
2423+++ glance/image_cache/prefetcher.py 2011-07-26 10:13:35 +0000
2424@@ -0,0 +1,90 @@
2425+# vim: tabstop=4 shiftwidth=4 softtabstop=4
2426+
2427+# Copyright 2011 OpenStack LLC.
2428+# All Rights Reserved.
2429+#
2430+# Licensed under the Apache License, Version 2.0 (the "License"); you may
2431+# not use this file except in compliance with the License. You may obtain
2432+# a copy of the License at
2433+#
2434+# http://www.apache.org/licenses/LICENSE-2.0
2435+#
2436+# Unless required by applicable law or agreed to in writing, software
2437+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
2438+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
2439+# License for the specific language governing permissions and limitations
2440+# under the License.
2441+
2442+"""
2443+Prefetches images into the Image Cache
2444+"""
2445+import logging
2446+import os
2447+import stat
2448+import time
2449+
2450+from glance.common import config
2451+from glance.common import context
2452+from glance.image_cache import ImageCache
2453+from glance import registry
2454+from glance.store import get_from_backend
2455+
2456+
2457+logger = logging.getLogger('glance.image_cache.prefetcher')
2458+
2459+
2460+class Prefetcher(object):
2461+ def __init__(self, options):
2462+ self.options = options
2463+ self.cache = ImageCache(options)
2464+
2465+ def fetch_image_into_cache(self, image_id):
2466+ ctx = context.RequestContext(is_admin=True, show_deleted=True)
2467+ image_meta = registry.get_image_metadata(
2468+ self.options, ctx, image_id)
2469+ with self.cache.open(image_meta, "wb") as cache_file:
2470+ chunks = get_from_backend(image_meta['location'],
2471+ expected_size=image_meta['size'],
2472+ options=self.options)
2473+ for chunk in chunks:
2474+ cache_file.write(chunk)
2475+
2476+ def run(self):
2477+ if self.cache.is_currently_prefetching_any_images():
2478+ logger.debug("Currently prefetching, going back to sleep...")
2479+ return
2480+
2481+ try:
2482+ image_id = self.cache.pop_prefetch_item()
2483+ except IndexError:
2484+ logger.debug("Nothing to prefetch, going back to sleep...")
2485+ return
2486+
2487+ if self.cache.hit(image_id):
2488+ logger.warn("Image %s is already in the cache, deleting "
2489+ "prefetch job and going back to sleep...", image_id)
2490+ self.cache.delete_queued_prefetch_image(image_id)
2491+ return
2492+
2493+ # NOTE(sirp): if someone is already downloading an image that is in
2494+ # the prefetch queue, then go ahead and delete that item and try to
2495+ # prefetch another
2496+ if self.cache.is_image_currently_being_written(image_id):
2497+ logger.warn("Image %s is already being cached, deleting "
2498+ "prefetch job and going back to sleep...", image_id)
2499+ self.cache.delete_queued_prefetch_image(image_id)
2500+ return
2501+
2502+ logger.debug("Prefetching '%s'", image_id)
2503+ self.cache.do_prefetch(image_id)
2504+
2505+ try:
2506+ self.fetch_image_into_cache(image_id)
2507+ finally:
2508+ self.cache.delete_prefetching_image(image_id)
2509+
2510+
2511+def app_factory(global_config, **local_conf):
2512+ conf = global_config.copy()
2513+ conf.update(local_conf)
2514+ return Prefetcher(conf)
2515
2516=== added file 'glance/image_cache/pruner.py'
2517--- glance/image_cache/pruner.py 1970-01-01 00:00:00 +0000
2518+++ glance/image_cache/pruner.py 2011-07-26 10:13:35 +0000
2519@@ -0,0 +1,115 @@
2520+# vim: tabstop=4 shiftwidth=4 softtabstop=4
2521+
2522+# Copyright 2011 OpenStack LLC.
2523+# All Rights Reserved.
2524+#
2525+# Licensed under the Apache License, Version 2.0 (the "License"); you may
2526+# not use this file except in compliance with the License. You may obtain
2527+# a copy of the License at
2528+#
2529+# http://www.apache.org/licenses/LICENSE-2.0
2530+#
2531+# Unless required by applicable law or agreed to in writing, software
2532+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
2533+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
2534+# License for the specific language governing permissions and limitations
2535+# under the License.
2536+
2537+"""
2538+Prunes the Image Cache
2539+"""
2540+import logging
2541+import os
2542+import stat
2543+import time
2544+
2545+from glance.common import config
2546+from glance.image_cache import ImageCache
2547+
2548+logger = logging.getLogger('glance.image_cache.pruner')
2549+
2550+
2551+class Pruner(object):
2552+ def __init__(self, options):
2553+ self.options = options
2554+ self.cache = ImageCache(options)
2555+
2556+ @property
2557+ def max_size(self):
2558+ default = 1 * 1024 * 1024 * 1024 # 1 GB
2559+ return config.get_option(
2560+ self.options, 'image_cache_max_size_bytes',
2561+ type='int', default=default)
2562+
2563+ @property
2564+ def percent_extra_to_free(self):
2565+ return config.get_option(
2566+ self.options, 'image_cache_percent_extra_to_free',
2567+ type='float', default=0.05)
2568+
2569+ def run(self):
2570+ self.prune_cache()
2571+
2572+ def prune_cache(self):
2573+ """Prune the cache using an LRU strategy"""
2574+
2575+ # NOTE(sirp): 'Recency' is determined via the filesystem, first using
2576+ # atime (access time) and falling back to mtime (modified time).
2577+ #
2578+ # It has become more common to disable access-time updates by setting
2579+ # the `noatime` option for the filesystem. `noatime` is NOT compatible
2580+ # with this method.
2581+ #
2582+ # If `noatime` needs to be supported, we will need to persist access
2583+ # times elsewhere (either as a separate file, in the DB, or as
2584+ # an xattr).
2585+ def get_stats():
2586+ stats = []
2587+ for path in self.cache.get_all_regular_files(self.cache.path):
2588+ file_info = os.stat(path)
2589+ stats.append((file_info[stat.ST_ATIME], # access time
2590+ file_info[stat.ST_MTIME], # modification time
2591+ file_info[stat.ST_SIZE], # size in bytes
2592+ path)) # absolute path
2593+ return stats
2594+
2595+ def prune_lru(stats, to_free):
2596+ # Sort older access and modified times to the back
2597+ stats.sort(reverse=True)
2598+
2599+ freed = 0
2600+ while to_free > 0:
2601+ atime, mtime, size, path = stats.pop()
2602+ logger.debug("deleting '%(path)s' to free %(size)d B",
2603+ locals())
2604+ os.unlink(path)
2605+ to_free -= size
2606+ freed += size
2607+
2608+ return freed
2609+
2610+ stats = get_stats()
2611+
2612+ # Check for overage
2613+ cur_size = sum(s[2] for s in stats)
2614+ max_size = self.max_size
2615+ logger.debug("cur_size=%(cur_size)d B max_size=%(max_size)d B",
2616+ locals())
2617+ if cur_size <= max_size:
2618+ logger.debug("cache has free space, skipping prune...")
2619+ return
2620+
2621+ overage = cur_size - max_size
2622+ extra = max_size * self.percent_extra_to_free
2623+ to_free = overage + extra
2624+ logger.debug("overage=%(overage)d B extra=%(extra)d B"
2625+ " total=%(to_free)d B", locals())
2626+
2627+ freed = prune_lru(stats, to_free)
2628+ logger.debug("finished pruning, freed %(freed)d bytes", locals())
2629+
2630+
2631+def app_factory(global_config, **local_conf):
2632+ conf = global_config.copy()
2633+ conf.update(local_conf)
2634+ return Pruner(conf)
2635
2636=== added file 'glance/image_cache/reaper.py'
2637--- glance/image_cache/reaper.py 1970-01-01 00:00:00 +0000
2638+++ glance/image_cache/reaper.py 2011-07-26 10:13:35 +0000
2639@@ -0,0 +1,45 @@
2640+# vim: tabstop=4 shiftwidth=4 softtabstop=4
2641+
2642+# Copyright 2011 OpenStack LLC.
2643+# All Rights Reserved.
2644+#
2645+# Licensed under the Apache License, Version 2.0 (the "License"); you may
2646+# not use this file except in compliance with the License. You may obtain
2647+# a copy of the License at
2648+#
2649+# http://www.apache.org/licenses/LICENSE-2.0
2650+#
2651+# Unless required by applicable law or agreed to in writing, software
2652+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
2653+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
2654+# License for the specific language governing permissions and limitations
2655+# under the License.
2656+
2657+"""
2658+Reaps any invalid cache entries that exceed the grace period
2659+"""
2660+import logging
2661+
2662+from glance.image_cache import ImageCache
2663+
2664+
2665+logger = logging.getLogger('glance.image_cache.reaper')
2666+
2667+
2668+class Reaper(object):
2669+ def __init__(self, options):
2670+ self.options = options
2671+ self.cache = ImageCache(options)
2672+
2673+ def run(self):
2674+ invalid_grace = int(self.options.get(
2675+ 'image_cache_invalid_entry_grace_period',
2676+ 3600))
2677+ self.cache.reap_invalid(grace=invalid_grace)
2678+ self.cache.reap_stalled()
2679+
2680+
2681+def app_factory(global_config, **local_conf):
2682+ conf = global_config.copy()
2683+ conf.update(local_conf)
2684+ return Reaper(conf)
2685
2686=== modified file 'glance/registry/__init__.py'
2687--- glance/registry/__init__.py 2011-05-16 14:43:33 +0000
2688+++ glance/registry/__init__.py 2011-07-26 10:13:35 +0000
2689@@ -26,33 +26,33 @@
2690 logger = logging.getLogger('glance.registry')
2691
2692
2693-def get_registry_client(options):
2694+def get_registry_client(options, cxt):
2695 host = options['registry_host']
2696 port = int(options['registry_port'])
2697- return client.RegistryClient(host, port)
2698-
2699-
2700-def get_images_list(options, **kwargs):
2701- c = get_registry_client(options)
2702+ return client.RegistryClient(host, port, auth_tok=cxt.auth_tok)
2703+
2704+
2705+def get_images_list(options, context, **kwargs):
2706+ c = get_registry_client(options, context)
2707 return c.get_images(**kwargs)
2708
2709
2710-def get_images_detail(options, **kwargs):
2711- c = get_registry_client(options)
2712+def get_images_detail(options, context, **kwargs):
2713+ c = get_registry_client(options, context)
2714 return c.get_images_detailed(**kwargs)
2715
2716
2717-def get_image_metadata(options, image_id):
2718- c = get_registry_client(options)
2719+def get_image_metadata(options, context, image_id):
2720+ c = get_registry_client(options, context)
2721 return c.get_image(image_id)
2722
2723
2724-def add_image_metadata(options, image_meta):
2725+def add_image_metadata(options, context, image_meta):
2726 if options['debug']:
2727 logger.debug("Adding image metadata...")
2728 _debug_print_metadata(image_meta)
2729
2730- c = get_registry_client(options)
2731+ c = get_registry_client(options, context)
2732 new_image_meta = c.add_image(image_meta)
2733
2734 if options['debug']:
2735@@ -63,12 +63,13 @@
2736 return new_image_meta
2737
2738
2739-def update_image_metadata(options, image_id, image_meta, purge_props=False):
2740+def update_image_metadata(options, context, image_id, image_meta,
2741+ purge_props=False):
2742 if options['debug']:
2743 logger.debug("Updating image metadata for image %s...", image_id)
2744 _debug_print_metadata(image_meta)
2745
2746- c = get_registry_client(options)
2747+ c = get_registry_client(options, context)
2748 new_image_meta = c.update_image(image_id, image_meta, purge_props)
2749
2750 if options['debug']:
2751@@ -79,9 +80,9 @@
2752 return new_image_meta
2753
2754
2755-def delete_image_metadata(options, image_id):
2756+def delete_image_metadata(options, context, image_id):
2757 logger.debug("Deleting image metadata for image %s...", image_id)
2758- c = get_registry_client(options)
2759+ c = get_registry_client(options, context)
2760 return c.delete_image(image_id)
2761
2762
2763
2764=== modified file 'glance/registry/client.py'
2765--- glance/registry/client.py 2011-06-26 20:43:41 +0000
2766+++ glance/registry/client.py 2011-07-26 10:13:35 +0000
2767@@ -33,17 +33,17 @@
2768
2769 DEFAULT_PORT = 9191
2770
2771- def __init__(self, host, port=None, use_ssl=False):
2772+ def __init__(self, host, port=None, use_ssl=False, auth_tok=None):
2773 """
2774 Creates a new client to a Glance Registry service.
2775
2776 :param host: The host where Glance resides
2777 :param port: The port where Glance resides (defaults to 9191)
2778 :param use_ssl: Should we use HTTPS? (defaults to False)
2779+ :param auth_tok: The auth token to pass to the server
2780 """
2781-
2782 port = port or self.DEFAULT_PORT
2783- super(RegistryClient, self).__init__(host, port, use_ssl)
2784+ super(RegistryClient, self).__init__(host, port, use_ssl, auth_tok)
2785
2786 def get_images(self, **kwargs):
2787 """
2788
2789=== modified file 'glance/registry/db/api.py'
2790--- glance/registry/db/api.py 2011-06-26 20:56:17 +0000
2791+++ glance/registry/db/api.py 2011-07-26 10:13:35 +0000
2792@@ -24,6 +24,7 @@
2793 import logging
2794
2795 from sqlalchemy import asc, create_engine, desc
2796+from sqlalchemy.exc import IntegrityError
2797 from sqlalchemy.ext.declarative import declarative_base
2798 from sqlalchemy.orm import exc
2799 from sqlalchemy.orm import joinedload
2800@@ -33,7 +34,6 @@
2801 from glance.common import config
2802 from glance.common import exception
2803 from glance.common import utils
2804-from glance.registry.db import migration
2805 from glance.registry.db import models
2806
2807 _ENGINE = None
2808@@ -46,12 +46,14 @@
2809
2810 IMAGE_ATTRS = BASE_MODEL_ATTRS | set(['name', 'status', 'size',
2811 'disk_format', 'container_format',
2812- 'is_public', 'location', 'checksum'])
2813+ 'is_public', 'location', 'checksum',
2814+ 'owner'])
2815
2816 CONTAINER_FORMATS = ['ami', 'ari', 'aki', 'bare', 'ovf']
2817 DISK_FORMATS = ['ami', 'ari', 'aki', 'vhd', 'vmdk', 'raw', 'qcow2', 'vdi',
2818 'iso']
2819-STATUSES = ['active', 'saving', 'queued', 'killed']
2820+STATUSES = ['active', 'saving', 'queued', 'killed', 'pending_delete',
2821+ 'deleted']
2822
2823
2824 def configure_db(options):
2825@@ -77,7 +79,7 @@
2826 elif verbose:
2827 logger.setLevel(logging.INFO)
2828
2829- migration.db_sync(options)
2830+ models.register_models(_ENGINE)
2831
2832
2833 def get_session(autocommit=True, expire_on_commit=False):
2834@@ -97,10 +99,10 @@
2835
2836
2837 def image_update(context, image_id, values, purge_props=False):
2838- """Set the given properties on an image and update it.
2839-
2840- Raises NotFound if image does not exist.
2841-
2842+ """
2843+ Set the given properties on an image and update it.
2844+
2845+ :raises NotFound if image does not exist.
2846 """
2847 return _image_update(context, values, image_id, purge_props)
2848
2849@@ -120,7 +122,7 @@
2850 """Get an image or raise if it does not exist."""
2851 session = session or get_session()
2852 try:
2853- return session.query(models.Image).\
2854+ image = session.query(models.Image).\
2855 options(joinedload(models.Image.properties)).\
2856 filter_by(deleted=_deleted(context)).\
2857 filter_by(id=image_id).\
2858@@ -128,10 +130,40 @@
2859 except exc.NoResultFound:
2860 raise exception.NotFound("No image found with ID %s" % image_id)
2861
2862-
2863-def image_get_all_public(context, filters=None, marker=None, limit=None,
2864- sort_key='created_at', sort_dir='desc'):
2865- """Get all public images that match zero or more filters.
2866+ # Make sure they can look at it
2867+ if not context.is_image_visible(image):
2868+ raise exception.NotAuthorized("Image not visible to you")
2869+
2870+ return image
2871+
2872+
2873+def image_get_all_pending_delete(context, delete_time=None, limit=None):
2874+ """Get all images that are pending deletion
2875+
2876+ :param limit: maximum number of images to return
2877+ """
2878+ session = get_session()
2879+ query = session.query(models.Image).\
2880+ options(joinedload(models.Image.properties)).\
2881+ filter_by(deleted=True).\
2882+ filter(models.Image.status == 'pending_delete')
2883+
2884+ if delete_time:
2885+ query = query.filter(models.Image.deleted_at <= delete_time)
2886+
2887+ query = query.order_by(desc(models.Image.deleted_at)).\
2888+ order_by(desc(models.Image.id))
2889+
2890+ if limit:
2891+ query = query.limit(limit)
2892+
2893+ return query.all()
2894+
2895+
2896+def image_get_all(context, filters=None, marker=None, limit=None,
2897+ sort_key='created_at', sort_dir='desc'):
2898+ """
2899+ Get all images that match zero or more filters.
2900
2901 :param filters: dict of filter keys and values. If a 'properties'
2902 key is present, it is treated as a dict of key/value
2903@@ -147,7 +179,6 @@
2904 query = session.query(models.Image).\
2905 options(joinedload(models.Image.properties)).\
2906 filter_by(deleted=_deleted(context)).\
2907- filter_by(is_public=True).\
2908 filter(models.Image.status != 'killed')
2909
2910 sort_dir_func = {
2911@@ -168,11 +199,19 @@
2912 query = query.filter(models.Image.size <= filters['size_max'])
2913 del filters['size_max']
2914
2915+ if 'is_public' in filters and filters['is_public'] is not None:
2916+ the_filter = models.Image.is_public == filters['is_public']
2917+ if filters['is_public'] and context.owner is not None:
2918+ the_filter = or_(the_filter, models.Image.owner == context.owner)
2919+ query = query.filter(the_filter)
2920+ del filters['is_public']
2921+
2922 for (k, v) in filters.pop('properties', {}).items():
2923 query = query.filter(models.Image.properties.any(name=k, value=v))
2924
2925 for (k, v) in filters.items():
2926- query = query.filter(getattr(models.Image, k) == v)
2927+ if v is not None:
2928+ query = query.filter(getattr(models.Image, k) == v)
2929
2930 if marker != None:
2931 # images returned should be created before the image defined by marker
2932@@ -189,7 +228,8 @@
2933
2934
2935 def _drop_protected_attrs(model_class, values):
2936- """Removed protected attributes from values dictionary using the models
2937+ """
2938+ Removed protected attributes from values dictionary using the models
2939 __protected_attributes__ field.
2940 """
2941 for attr in model_class.__protected_attributes__:
2942@@ -204,7 +244,6 @@
2943
2944 :param values: Mapping of image metadata to check
2945 """
2946-
2947 status = values.get('status')
2948 disk_format = values.get('disk_format')
2949 container_format = values.get('container_format')
2950@@ -237,13 +276,13 @@
2951
2952
2953 def _image_update(context, values, image_id, purge_props=False):
2954- """Used internally by image_create and image_update
2955+ """
2956+ Used internally by image_create and image_update
2957
2958 :param context: Request context
2959 :param values: A dict of attributes to set
2960 :param image_id: If None, create the image, otherwise, find and update it
2961 """
2962-
2963 session = get_session()
2964 with session.begin():
2965
2966@@ -273,7 +312,11 @@
2967 # idiotic.
2968 validate_image(image_ref.to_dict())
2969
2970- image_ref.save(session=session)
2971+ try:
2972+ image_ref.save(session=session)
2973+ except IntegrityError, e:
2974+ raise exception.Duplicate("Image ID %s already exists!"
2975+ % values['id'])
2976
2977 _set_properties_for_image(context, image_ref, properties, purge_props,
2978 session)
2979@@ -325,7 +368,8 @@
2980
2981
2982 def _image_property_update(context, prop_ref, values, session=None):
2983- """Used internally by image_property_create and image_property_update
2984+ """
2985+ Used internally by image_property_create and image_property_update
2986 """
2987 _drop_protected_attrs(models.ImageProperty, values)
2988 values["deleted"] = False
2989@@ -335,7 +379,8 @@
2990
2991
2992 def image_property_delete(context, prop_ref, session=None):
2993- """Used internally by image_property_create and image_property_update
2994+ """
2995+ Used internally by image_property_create and image_property_update
2996 """
2997 prop_ref.update(dict(deleted=True))
2998 prop_ref.save(session=session)
2999@@ -344,10 +389,12 @@
3000
3001 # pylint: disable-msg=C0111
3002 def _deleted(context):
3003- """Calculates whether to include deleted objects based on context.
3004-
3005+ """
3006+ Calculates whether to include deleted objects based on context.
3007 Currently just looks for a flag called deleted in the context dict.
3008 """
3009+ if hasattr(context, 'show_deleted'):
3010+ return context.show_deleted
3011 if not hasattr(context, 'get'):
3012 return False
3013 return context.get('deleted', False)
3014
3015=== modified file 'glance/registry/db/migrate_repo/schema.py'
3016--- glance/registry/db/migrate_repo/schema.py 2011-03-31 14:43:28 +0000
3017+++ glance/registry/db/migrate_repo/schema.py 2011-07-26 10:13:35 +0000
3018@@ -51,7 +51,8 @@
3019
3020
3021 def from_migration_import(module_name, fromlist):
3022- """Import a migration file and return the module
3023+ """
3024+ Import a migration file and return the module
3025
3026 :param module_name: name of migration module to import from
3027 (ex: 001_add_images_table)
3028@@ -84,7 +85,6 @@
3029 images = define_images_table(meta)
3030
3031 # Refer to images table
3032-
3033 """
3034 module_path = 'glance.registry.db.migrate_repo.versions.%s' % module_name
3035 module = __import__(module_path, globals(), locals(), fromlist, -1)
3036
3037=== added file 'glance/registry/db/migrate_repo/versions/007_add_owner.py'
3038--- glance/registry/db/migrate_repo/versions/007_add_owner.py 1970-01-01 00:00:00 +0000
3039+++ glance/registry/db/migrate_repo/versions/007_add_owner.py 2011-07-26 10:13:35 +0000
3040@@ -0,0 +1,82 @@
3041+# vim: tabstop=4 shiftwidth=4 softtabstop=4
3042+
3043+# Copyright 2011 OpenStack LLC.
3044+# All Rights Reserved.
3045+#
3046+# Licensed under the Apache License, Version 2.0 (the "License"); you may
3047+# not use this file except in compliance with the License. You may obtain
3048+# a copy of the License at
3049+#
3050+# http://www.apache.org/licenses/LICENSE-2.0
3051+#
3052+# Unless required by applicable law or agreed to in writing, software
3053+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
3054+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
3055+# License for the specific language governing permissions and limitations
3056+# under the License.
3057+
3058+from migrate.changeset import *
3059+from sqlalchemy import *
3060+from sqlalchemy.sql import and_, not_
3061+
3062+from glance.registry.db.migrate_repo.schema import (
3063+ Boolean, DateTime, BigInteger, Integer, String,
3064+ Text, from_migration_import)
3065+
3066+
3067+def get_images_table(meta):
3068+ """
3069+ Returns the Table object for the images table that corresponds to
3070+ the images table definition of this version.
3071+ """
3072+ images = Table('images', meta,
3073+ Column('id', Integer(), primary_key=True, nullable=False),
3074+ Column('name', String(255)),
3075+ Column('disk_format', String(20)),
3076+ Column('container_format', String(20)),
3077+ Column('size', BigInteger()),
3078+ Column('status', String(30), nullable=False),
3079+ Column('is_public', Boolean(), nullable=False, default=False,
3080+ index=True),
3081+ Column('location', Text()),
3082+ Column('created_at', DateTime(), nullable=False),
3083+ Column('updated_at', DateTime()),
3084+ Column('deleted_at', DateTime()),
3085+ Column('deleted', Boolean(), nullable=False, default=False,
3086+ index=True),
3087+ Column('checksum', String(32)),
3088+ Column('owner', String(255)),
3089+ mysql_engine='InnoDB',
3090+ useexisting=True)
3091+
3092+ return images
3093+
3094+
3095+def get_image_properties_table(meta):
3096+ """
3097+ No changes to the image properties table from 006...
3098+ """
3099+ (get_image_properties_table,) = from_migration_import(
3100+ '006_key_to_name', ['get_image_properties_table'])
3101+
3102+ image_properties = get_image_properties_table(meta)
3103+ return image_properties
3104+
3105+
3106+def upgrade(migrate_engine):
3107+ meta = MetaData()
3108+ meta.bind = migrate_engine
3109+
3110+ images = get_images_table(meta)
3111+
3112+ owner = Column('owner', String(255))
3113+ owner.create(images)
3114+
3115+
3116+def downgrade(migrate_engine):
3117+ meta = MetaData()
3118+ meta.bind = migrate_engine
3119+
3120+ images = get_images_table(meta)
3121+
3122+ images.columns['owner'].drop()
3123
3124=== modified file 'glance/registry/db/migration.py'
3125--- glance/registry/db/migration.py 2011-03-23 13:45:11 +0000
3126+++ glance/registry/db/migration.py 2011-07-26 10:13:35 +0000
3127@@ -33,7 +33,8 @@
3128
3129
3130 def db_version(options):
3131- """Return the database's current migration number
3132+ """
3133+ Return the database's current migration number
3134
3135 :param options: options dict
3136 :retval version number
3137@@ -49,7 +50,8 @@
3138
3139
3140 def upgrade(options, version=None):
3141- """Upgrade the database's current migration level
3142+ """
3143+ Upgrade the database's current migration level
3144
3145 :param options: options dict
3146 :param version: version to upgrade (defaults to latest)
3147@@ -65,7 +67,8 @@
3148
3149
3150 def downgrade(options, version):
3151- """Downgrade the database's current migration level
3152+ """
3153+ Downgrade the database's current migration level
3154
3155 :param options: options dict
3156 :param version: version to downgrade to
3157@@ -80,7 +83,8 @@
3158
3159
3160 def version_control(options):
3161- """Place a database under migration control
3162+ """
3163+ Place a database under migration control
3164
3165 :param options: options dict
3166 """
3167@@ -94,7 +98,8 @@
3168
3169
3170 def _version_control(options):
3171- """Place a database under migration control
3172+ """
3173+ Place a database under migration control
3174
3175 :param options: options dict
3176 """
3177@@ -104,7 +109,8 @@
3178
3179
3180 def db_sync(options, version=None):
3181- """Place a database under migration control and perform an upgrade
3182+ """
3183+ Place a database under migration control and perform an upgrade
3184
3185 :param options: options dict
3186 :retval version number
3187
3188=== modified file 'glance/registry/db/models.py'
3189--- glance/registry/db/models.py 2011-04-07 19:07:36 +0000
3190+++ glance/registry/db/models.py 2011-07-26 10:13:35 +0000
3191@@ -105,6 +105,7 @@
3192 is_public = Column(Boolean, nullable=False, default=False)
3193 location = Column(Text)
3194 checksum = Column(String(32))
3195+ owner = Column(String(255))
3196
3197
3198 class ImageProperty(BASE, ModelBase):
3199@@ -118,3 +119,12 @@
3200
3201 name = Column(String(255), index=True, nullable=False)
3202 value = Column(Text)
3203+
3204+
3205+def register_models(engine):
3206+ """
3207+ Creates database tables for all models with the given engine
3208+ """
3209+ models = (Image, ImageProperty)
3210+ for model in models:
3211+ model.metadata.create_all(engine)
3212
3213=== modified file 'glance/registry/server.py'
3214--- glance/registry/server.py 2011-06-27 18:05:29 +0000
3215+++ glance/registry/server.py 2011-07-26 10:13:35 +0000
3216@@ -56,8 +56,19 @@
3217 self.options = options
3218 db_api.configure_db(options)
3219
3220+ def _get_images(self, context, **params):
3221+ """
3222+ Get images, wrapping in exception if necessary.
3223+ """
3224+ try:
3225+ return db_api.image_get_all(context, **params)
3226+ except exception.NotFound, e:
3227+ msg = "Invalid marker. Image could not be found."
3228+ raise exc.HTTPBadRequest(explanation=msg)
3229+
3230 def index(self, req):
3231- """Return a basic filtered list of public, non-deleted images
3232+ """
3233+ Return a basic filtered list of public, non-deleted images
3234
3235 :param req: the Request object coming from the wsgi layer
3236 :retval a mapping of the following form::
3237@@ -74,10 +85,9 @@
3238 'container_format': <CONTAINER_FORMAT>,
3239 'checksum': <CHECKSUM>
3240 }
3241-
3242 """
3243 params = self._get_query_params(req)
3244- images = db_api.image_get_all_public(None, **params)
3245+ images = self._get_images(req.context, **params)
3246
3247 results = []
3248 for image in images:
3249@@ -88,7 +98,8 @@
3250 return dict(images=results)
3251
3252 def detail(self, req):
3253- """Return a filtered list of public, non-deleted images in detail
3254+ """
3255+ Return a filtered list of public, non-deleted images in detail
3256
3257 :param req: the Request object coming from the wsgi layer
3258 :retval a mapping of the following form::
3259@@ -97,11 +108,10 @@
3260
3261 Where image_list is a sequence of mappings containing
3262 all image model fields.
3263-
3264 """
3265 params = self._get_query_params(req)
3266- images = db_api.image_get_all_public(None, **params)
3267
3268+ images = self._get_images(req.context, **params)
3269 image_dicts = [make_image_dict(i) for i in images]
3270 return dict(images=image_dicts)
3271
3272@@ -127,15 +137,20 @@
3273 return params
3274
3275 def _get_filters(self, req):
3276- """Return a dictionary of query param filters from the request
3277+ """
3278+ Return a dictionary of query param filters from the request
3279
3280 :param req: the Request object coming from the wsgi layer
3281 :retval a dict of key/value filters
3282-
3283 """
3284 filters = {}
3285 properties = {}
3286
3287+ if req.context.is_admin:
3288+ # Only admin gets to look for non-public images
3289+ filters['is_public'] = self._get_is_public(req)
3290+ else:
3291+ filters['is_public'] = True
3292 for param in req.str_params:
3293 if param in SUPPORTED_FILTERS:
3294 filters[param] = req.str_params.get(param)
3295@@ -191,12 +206,36 @@
3296 raise exc.HTTPBadRequest(explanation=msg)
3297 return sort_dir
3298
3299+ def _get_is_public(self, req):
3300+ """Parse is_public into something usable."""
3301+ is_public = req.str_params.get('is_public', None)
3302+
3303+ if is_public is None:
3304+ # NOTE(vish): This preserves the default value of showing only
3305+ # public images.
3306+ return True
3307+ is_public = is_public.lower()
3308+ if is_public == 'none':
3309+ return None
3310+ elif is_public == 'true' or is_public == '1':
3311+ return True
3312+ elif is_public == 'false' or is_public == '0':
3313+ return False
3314+ else:
3315+ raise exc.HTTPBadRequest("is_public must be None, True, or False")
3316+
3317 def show(self, req, id):
3318 """Return data about the given image id."""
3319 try:
3320- image = db_api.image_get(None, id)
3321+ image = db_api.image_get(req.context, id)
3322 except exception.NotFound:
3323 raise exc.HTTPNotFound()
3324+ except exception.NotAuthorized:
3325+ # If it's private and doesn't belong to them, don't let on
3326+ # that it exists
3327+ logger.info("Access by %s to image %s denied" %
3328+ (req.context.user, id))
3329+ raise exc.HTTPNotFound()
3330
3331 return dict(image=make_image_dict(image))
3332
3333@@ -208,13 +247,20 @@
3334 :param id: The opaque internal identifier for the image
3335
3336 :retval Returns 200 if delete was successful, a fault if not.
3337+ """
3338+ if req.context.read_only:
3339+ raise exc.HTTPForbidden()
3340
3341- """
3342- context = None
3343 try:
3344- db_api.image_destroy(context, id)
3345+ db_api.image_destroy(req.context, id)
3346 except exception.NotFound:
3347 return exc.HTTPNotFound()
3348+ except exception.NotAuthorized:
3349+ # If it's private and doesn't belong to them, don't let on
3350+ # that it exists
3351+ logger.info("Access by %s to image %s denied" %
3352+ (req.context.user, id))
3353+ raise exc.HTTPNotFound()
3354
3355 def create(self, req, body):
3356 """
3357@@ -226,16 +272,21 @@
3358 :retval Returns the newly-created image information as a mapping,
3359 which will include the newly-created image's internal id
3360 in the 'id' field
3361-
3362 """
3363+ if req.context.read_only:
3364+ raise exc.HTTPForbidden()
3365+
3366 image_data = body['image']
3367
3368 # Ensure the image has a status set
3369 image_data.setdefault('status', 'active')
3370
3371- context = None
3372+ # Set up the image owner
3373+ if not req.context.is_admin or 'owner' not in image_data:
3374+ image_data['owner'] = req.context.owner
3375+
3376 try:
3377- image_data = db_api.image_create(context, image_data)
3378+ image_data = db_api.image_create(req.context, image_data)
3379 return dict(image=make_image_dict(image_data))
3380 except exception.Duplicate:
3381 msg = ("Image with identifier %s already exists!" % id)
3382@@ -247,27 +298,34 @@
3383 return exc.HTTPBadRequest(msg)
3384
3385 def update(self, req, id, body):
3386- """Updates an existing image with the registry.
3387+ """
3388+ Updates an existing image with the registry.
3389
3390 :param req: wsgi Request object
3391 :param body: Dictionary of information about the image
3392 :param id: The opaque internal identifier for the image
3393
3394 :retval Returns the updated image information as a mapping,
3395-
3396 """
3397+ if req.context.read_only:
3398+ raise exc.HTTPForbidden()
3399+
3400 image_data = body['image']
3401
3402+ # Prohibit modification of 'owner'
3403+ if not req.context.is_admin and 'owner' in image_data:
3404+ del image_data['owner']
3405+
3406 purge_props = req.headers.get("X-Glance-Registry-Purge-Props", "false")
3407- context = None
3408 try:
3409 logger.debug("Updating image %(id)s with metadata: %(image_data)r"
3410 % locals())
3411 if purge_props == "true":
3412- updated_image = db_api.image_update(context, id, image_data,
3413- True)
3414+ updated_image = db_api.image_update(req.context, id,
3415+ image_data, True)
3416 else:
3417- updated_image = db_api.image_update(context, id, image_data)
3418+ updated_image = db_api.image_update(req.context, id,
3419+ image_data)
3420 return dict(image=make_image_dict(updated_image))
3421 except exception.Invalid, e:
3422 msg = ("Failed to update image metadata. "
3423@@ -278,6 +336,14 @@
3424 raise exc.HTTPNotFound(body='Image not found',
3425 request=req,
3426 content_type='text/plain')
3427+ except exception.NotAuthorized:
3428+ # If it's private and doesn't belong to them, don't let on
3429+ # that it exists
3430+ logger.info("Access by %s to image %s denied" %
3431+ (req.context.user, id))
3432+ raise exc.HTTPNotFound(body='Image not found',
3433+ request=req,
3434+ content_type='text/plain')
3435
3436
3437 def create_resource(options):
3438
3439=== modified file 'glance/store/__init__.py'
3440--- glance/store/__init__.py 2011-03-23 14:16:10 +0000
3441+++ glance/store/__init__.py 2011-07-26 10:13:35 +0000
3442@@ -15,11 +15,17 @@
3443 # License for the specific language governing permissions and limitations
3444 # under the License.
3445
3446+import logging
3447 import optparse
3448 import os
3449 import urlparse
3450
3451-from glance.common import exception
3452+from glance import registry
3453+from glance.common import config, exception
3454+from glance.store import location
3455+
3456+
3457+logger = logging.getLogger('glance.store')
3458
3459
3460 # TODO(sirp): should this be moved out to common/utils.py ?
3461@@ -74,70 +80,48 @@
3462 def get_from_backend(uri, **kwargs):
3463 """Yields chunks of data from backend specified by uri"""
3464
3465- parsed_uri = urlparse.urlparse(uri)
3466- scheme = parsed_uri.scheme
3467-
3468- backend_class = get_backend_class(scheme)
3469-
3470- return backend_class.get(parsed_uri, **kwargs)
3471+ loc = location.get_location_from_uri(uri)
3472+ backend_class = get_backend_class(loc.store_name)
3473+
3474+ return backend_class.get(loc, **kwargs)
3475
3476
3477 def delete_from_backend(uri, **kwargs):
3478 """Removes chunks of data from backend specified by uri"""
3479
3480- parsed_uri = urlparse.urlparse(uri)
3481- scheme = parsed_uri.scheme
3482-
3483- backend_class = get_backend_class(scheme)
3484+ loc = location.get_location_from_uri(uri)
3485+ backend_class = get_backend_class(loc.store_name)
3486
3487 if hasattr(backend_class, 'delete'):
3488- return backend_class.delete(parsed_uri, **kwargs)
3489-
3490-
3491-def get_store_from_location(location):
3492+ return backend_class.delete(loc, **kwargs)
3493+
3494+
3495+def get_store_from_location(uri):
3496 """
3497 Given a location (assumed to be a URL), attempt to determine
3498 the store from the location. We use here a simple guess that
3499 the scheme of the parsed URL is the store...
3500
3501- :param location: Location to check for the store
3502- """
3503- loc_pieces = urlparse.urlparse(location)
3504- return loc_pieces.scheme
3505-
3506-
3507-def parse_uri_tokens(parsed_uri, example_url):
3508- """
3509- Given a URI and an example_url, attempt to parse the uri to assemble an
3510- authurl. This method returns the user, key, authurl, referenced container,
3511- and the object we're looking for in that container.
3512-
3513- Parsing the uri is three phases:
3514- 1) urlparse to split the tokens
3515- 2) use RE to split on @ and /
3516- 3) reassemble authurl
3517-
3518- """
3519- path = parsed_uri.path.lstrip('//')
3520- netloc = parsed_uri.netloc
3521-
3522- try:
3523+ :param uri: Location to check for the store
3524+ """
3525+ loc = location.get_location_from_uri(uri)
3526+ return loc.store_name
3527+
3528+
3529+def schedule_delete_from_backend(uri, options, context, id, **kwargs):
3530+ """
3531+ Given a uri and a time, schedule the deletion of an image.
3532+ """
3533+ use_delay = config.get_option(options, 'delayed_delete', type='bool',
3534+ default=False)
3535+ if not use_delay:
3536+ registry.update_image_metadata(options, context, id,
3537+ {'status': 'deleted'})
3538 try:
3539- creds, netloc = netloc.split('@')
3540- except ValueError:
3541- # Python 2.6.1 compat
3542- # see lp659445 and Python issue7904
3543- creds, path = path.split('@')
3544- user, key = creds.split(':')
3545- path_parts = path.split('/')
3546- obj = path_parts.pop()
3547- container = path_parts.pop()
3548- except (ValueError, IndexError):
3549- raise BackendException(
3550- "Expected four values to unpack in: %s:%s. "
3551- "Should have received something like: %s."
3552- % (parsed_uri.scheme, parsed_uri.path, example_url))
3553-
3554- authurl = "https://%s" % '/'.join(path_parts)
3555-
3556- return user, key, authurl, container, obj
3557+ return delete_from_backend(uri, **kwargs)
3558+ except (UnsupportedBackend, exception.NotFound):
3559+ msg = "Failed to delete image from store (%s). "
3560+ logger.error(msg % uri)
3561+
3562+ registry.update_image_metadata(options, context, id,
3563+ {'status': 'pending_delete'})
3564
3565=== modified file 'glance/store/filesystem.py'
3566--- glance/store/filesystem.py 2011-03-08 15:22:44 +0000
3567+++ glance/store/filesystem.py 2011-07-26 10:13:35 +0000
3568@@ -26,9 +26,39 @@
3569
3570 from glance.common import exception
3571 import glance.store
3572+import glance.store.location
3573
3574 logger = logging.getLogger('glance.store.filesystem')
3575
3576+glance.store.location.add_scheme_map({'file': 'filesystem'})
3577+
3578+
3579+class StoreLocation(glance.store.location.StoreLocation):
3580+
3581+ """Class describing a Filesystem URI"""
3582+
3583+ def process_specs(self):
3584+ self.scheme = self.specs.get('scheme', 'file')
3585+ self.path = self.specs.get('path')
3586+
3587+ def get_uri(self):
3588+ return "file://%s" % self.path
3589+
3590+ def parse_uri(self, uri):
3591+ """
3592+ Parse URLs. This method fixes an issue where credentials specified
3593+ in the URL are interpreted differently in Python 2.6.1+ than prior
3594+ versions of Python.
3595+ """
3596+ pieces = urlparse.urlparse(uri)
3597+ assert pieces.scheme == 'file'
3598+ self.scheme = pieces.scheme
3599+ path = (pieces.netloc + pieces.path).strip()
3600+ if path == '':
3601+ reason = "No path specified"
3602+ raise exception.BadStoreUri(uri, reason)
3603+ self.path = path
3604+
3605
3606 class ChunkedFile(object):
3607
3608@@ -64,13 +94,19 @@
3609
3610 class FilesystemBackend(glance.store.Backend):
3611 @classmethod
3612- def get(cls, parsed_uri, expected_size=None, options=None):
3613- """ Filesystem-based backend
3614-
3615- file:///path/to/file.tar.gz.0
3616- """
3617-
3618- filepath = parsed_uri.path
3619+ def get(cls, location, expected_size=None, options=None):
3620+ """
3621+ Takes a `glance.store.location.Location` object that indicates
3622+ where to find the image file, and returns a generator to use in
3623+ reading the image file.
3624+
3625+ :location `glance.store.location.Location` object, supplied
3626+ from glance.store.location.get_location_from_uri()
3627+
3628+ :raises NotFound if file does not exist
3629+ """
3630+ loc = location.store_location
3631+ filepath = loc.path
3632 if not os.path.exists(filepath):
3633 raise exception.NotFound("Image file %s not found" % filepath)
3634 else:
3635@@ -79,17 +115,19 @@
3636 return ChunkedFile(filepath)
3637
3638 @classmethod
3639- def delete(cls, parsed_uri):
3640+ def delete(cls, location):
3641 """
3642- Removes a file from the filesystem backend.
3643+ Takes a `glance.store.location.Location` object that indicates
3644+ where to find the image file to delete
3645
3646- :param parsed_uri: Parsed pieces of URI in form of::
3647- file:///path/to/filename.ext
3648+ :location `glance.store.location.Location` object, supplied
3649+ from glance.store.location.get_location_from_uri()
3650
3651 :raises NotFound if file does not exist
3652 :raises NotAuthorized if cannot delete because of permissions
3653 """
3654- fn = parsed_uri.path
3655+ loc = location.store_location
3656+ fn = loc.path
3657 if os.path.exists(fn):
3658 try:
3659 logger.debug("Deleting image at %s", fn)
3660
3661=== modified file 'glance/store/http.py'
3662--- glance/store/http.py 2011-03-17 19:57:47 +0000
3663+++ glance/store/http.py 2011-07-26 10:13:35 +0000
3664@@ -16,31 +16,104 @@
3665 # under the License.
3666
3667 import httplib
3668+import urlparse
3669
3670+from glance.common import exception
3671 import glance.store
3672+import glance.store.location
3673+
3674+glance.store.location.add_scheme_map({'http': 'http',
3675+ 'https': 'http'})
3676+
3677+
3678+class StoreLocation(glance.store.location.StoreLocation):
3679+
3680+ """Class describing an HTTP(S) URI"""
3681+
3682+ def process_specs(self):
3683+ self.scheme = self.specs.get('scheme', 'http')
3684+ self.netloc = self.specs['netloc']
3685+ self.user = self.specs.get('user')
3686+ self.password = self.specs.get('password')
3687+ self.path = self.specs.get('path')
3688+
3689+ def _get_credstring(self):
3690+ if self.user:
3691+ return '%s:%s@' % (self.user, self.password)
3692+ return ''
3693+
3694+ def get_uri(self):
3695+ return "%s://%s%s%s" % (
3696+ self.scheme,
3697+ self._get_credstring(),
3698+ self.netloc,
3699+ self.path)
3700+
3701+ def parse_uri(self, uri):
3702+ """
3703+ Parse URLs. This method fixes an issue where credentials specified
3704+ in the URL are interpreted differently in Python 2.6.1+ than prior
3705+ versions of Python.
3706+ """
3707+ pieces = urlparse.urlparse(uri)
3708+ assert pieces.scheme in ('https', 'http')
3709+ self.scheme = pieces.scheme
3710+ netloc = pieces.netloc
3711+ path = pieces.path
3712+ try:
3713+ if '@' in netloc:
3714+ creds, netloc = netloc.split('@')
3715+ else:
3716+ creds = None
3717+ except ValueError:
3718+ # Python 2.6.1 compat
3719+ # see lp659445 and Python issue7904
3720+ if '@' in path:
3721+ creds, path = path.split('@')
3722+ else:
3723+ creds = None
3724+ if creds:
3725+ try:
3726+ self.user, self.password = creds.split(':')
3727+ except ValueError:
3728+ reason = ("Credentials '%s' not well-formatted."
3729+ % "".join(creds))
3730+ raise exception.BadStoreUri(uri, reason)
3731+ else:
3732+ self.user = None
3733+ if netloc == '':
3734+ reason = "No address specified in HTTP URL"
3735+ raise exception.BadStoreUri(uri, reason)
3736+ self.netloc = netloc
3737+ self.path = path
3738
3739
3740 class HTTPBackend(glance.store.Backend):
3741 """ An implementation of the HTTP Backend Adapter """
3742
3743 @classmethod
3744- def get(cls, parsed_uri, expected_size, options=None, conn_class=None):
3745- """Takes a parsed uri for an HTTP resource, fetches it, and yields the
3746- data.
3747+ def get(cls, location, expected_size, options=None, conn_class=None):
3748 """
3749+ Takes a `glance.store.location.Location` object that indicates
3750+ where to find the image file, and returns a generator from Swift
3751+ provided by Swift client's get_object() method.
3752
3753+ :location `glance.store.location.Location` object, supplied
3754+ from glance.store.location.get_location_from_uri()
3755+ """
3756+ loc = location.store_location
3757 if conn_class:
3758 pass # use the conn_class passed in
3759- elif parsed_uri.scheme == "http":
3760+ elif loc.scheme == "http":
3761 conn_class = httplib.HTTPConnection
3762- elif parsed_uri.scheme == "https":
3763+ elif loc.scheme == "https":
3764 conn_class = httplib.HTTPSConnection
3765 else:
3766 raise glance.store.BackendException(
3767 "scheme '%s' not supported for HTTPBackend")
3768
3769- conn = conn_class(parsed_uri.netloc)
3770- conn.request("GET", parsed_uri.path, "", {})
3771+ conn = conn_class(loc.netloc)
3772+ conn.request("GET", loc.path, "", {})
3773
3774 try:
3775 return glance.store._file_iter(conn.getresponse(), cls.CHUNKSIZE)
3776
3777=== added file 'glance/store/location.py'
3778--- glance/store/location.py 1970-01-01 00:00:00 +0000
3779+++ glance/store/location.py 2011-07-26 10:13:35 +0000
3780@@ -0,0 +1,182 @@
3781+# vim: tabstop=4 shiftwidth=4 softtabstop=4
3782+
3783+# Copyright 2011 OpenStack, LLC
3784+# All Rights Reserved.
3785+#
3786+# Licensed under the Apache License, Version 2.0 (the "License"); you may
3787+# not use this file except in compliance with the License. You may obtain
3788+# a copy of the License at
3789+#
3790+# http://www.apache.org/licenses/LICENSE-2.0
3791+#
3792+# Unless required by applicable law or agreed to in writing, software
3793+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
3794+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
3795+# License for the specific language governing permissions and limitations
3796+# under the License.
3797+
3798+"""
3799+A class that describes the location of an image in Glance.
3800+
3801+In Glance, an image can either be **stored** in Glance, or it can be
3802+**registered** in Glance but actually be stored somewhere else.
3803+
3804+We needed a class that could support the various ways that Glance
3805+describes where exactly an image is stored.
3806+
3807+An image in Glance has two location properties: the image URI
3808+and the image storage URI.
3809+
3810+The image URI is essentially the permalink identifier for the image.
3811+It is displayed in the output of various Glance API calls and,
3812+while read-only, is entirely user-facing. It shall **not** contain any
3813+security credential information at all. The Glance image URI shall
3814+be the host:port of that Glance API server along with /images/<IMAGE_ID>.
3815+
3816+The Glance storage URI is an internal URI structure that Glance
3817+uses to maintain critical information about how to access the images
3818+that it stores in its storage backends. It **does contain** security
3819+credentials and is **not** user-facing.
3820+"""
3821+
3822+import logging
3823+import urlparse
3824+
3825+from glance.common import exception
3826+from glance.common import utils
3827+
3828+logger = logging.getLogger('glance.store.location')
3829+
3830+SCHEME_TO_STORE_MAP = {}
3831+
3832+
3833+def get_location_from_uri(uri):
3834+ """
3835+ Given a URI, return a Location object that has had an appropriate
3836+ store parse the URI.
3837+
3838+ :param uri: A URI that could come from the end-user in the Location
3839+ attribute/header
3840+
3841+ Example URIs:
3842+ https://user:pass@example.com:80/images/some-id
3843+ http://images.oracle.com/123456
3844+ swift://user:account:pass@authurl.com/container/obj-id
3845+ swift+http://user:account:pass@authurl.com/container/obj-id
3846+ s3://accesskey:secretkey@s3.amazonaws.com/bucket/key-id
3847+ s3+https://accesskey:secretkey@s3.amazonaws.com/bucket/key-id
3848+ file:///var/lib/glance/images/1
3849+ """
3850+ # Add known stores to mapping... this gets past circular import
3851+ # issues. There's a better way to do this, but that's for another
3852+ # patch...
3853+ # TODO(jaypipes) Clear up these imports in refactor-stores blueprint
3854+ import glance.store.filesystem
3855+ import glance.store.http
3856+ import glance.store.s3
3857+ import glance.store.swift
3858+ pieces = urlparse.urlparse(uri)
3859+ if pieces.scheme not in SCHEME_TO_STORE_MAP.keys():
3860+ raise exception.UnknownScheme(pieces.scheme)
3861+ loc = Location(pieces.scheme, uri=uri)
3862+ return loc
3863+
3864+
3865+def add_scheme_map(scheme_map):
3866+ """
3867+ Given a mapping of 'scheme' to store_name, adds the mapping to the
3868+ known list of schemes.
3869+
3870+ Each store should call this method and let Glance know about which
3871+ schemes to map to a store name.
3872+ """
3873+ SCHEME_TO_STORE_MAP.update(scheme_map)
3874+
3875+
3876+class Location(object):
3877+
3878+ """
3879+ Class describing the location of an image that Glance knows about
3880+ """
3881+
3882+ def __init__(self, store_name, uri=None, image_id=None, store_specs=None):
3883+ """
3884+ Create a new Location object.
3885+
3886+ :param store_name: The string identifier of the storage backend
3887+ :param image_id: The identifier of the image in whatever storage
3888+ backend is used.
3889+ :param uri: Optional URI to construct location from
3890+ :param store_specs: Dictionary of information about the location
3891+ of the image that is dependent on the backend
3892+ store
3893+ """
3894+ self.store_name = store_name
3895+ self.image_id = image_id
3896+ self.store_specs = store_specs or {}
3897+ self.store_location = self._get_store_location()
3898+ if uri:
3899+ self.store_location.parse_uri(uri)
3900+
3901+ def _get_store_location(self):
3902+ """
3903+ We find the store module and then grab an instance of the store's
3904+ StoreLocation class which handles store-specific location information
3905+ """
3906+ try:
3907+ cls = utils.import_class('glance.store.%s.StoreLocation'
3908+ % SCHEME_TO_STORE_MAP[self.store_name])
3909+ return cls(self.store_specs)
3910+ except exception.NotFound:
3911+ logger.error("Unable to find StoreLocation class in store %s",
3912+ self.store_name)
3913+ return None
3914+
3915+ def get_store_uri(self):
3916+ """
3917+ Returns the Glance image URI, which is the host:port of the API server
3918+ along with /images/<IMAGE_ID>
3919+ """
3920+ return self.store_location.get_uri()
3921+
3922+ def get_uri(self):
3923+ return None
3924+
3925+
3926+class StoreLocation(object):
3927+
3928+ """
3929+ Base class that must be implemented by each store
3930+ """
3931+
3932+ def __init__(self, store_specs):
3933+ self.specs = store_specs
3934+ if self.specs:
3935+ self.process_specs()
3936+
3937+ def process_specs(self):
3938+ """
3939+ Subclasses should implement any processing of the self.specs collection
3940+ such as storing credentials and possibly establishing connections.
3941+ """
3942+ pass
3943+
3944+ def get_uri(self):
3945+ """
3946+ Subclasses should implement a method that returns an internal URI that,
3947+ when supplied to the StoreLocation instance, can be interpreted by the
3948+ StoreLocation's parse_uri() method. The URI returned from this method
3949+ shall never be public and only used internally within Glance, so it is
3950+ fine to encode credentials in this URI.
3951+ """
3952+ raise NotImplementedError("StoreLocation subclass must implement "
3953+ "get_uri()")
3954+
3955+ def parse_uri(self, uri):
3956+ """
3957+ Subclasses should implement a method that accepts a string URI and
3958+ sets appropriate internal fields such that a call to get_uri() will
3959+ return a proper internal URI
3960+ """
3961+ raise NotImplementedError("StoreLocation subclass must implement "
3962+ "parse_uri()")
3963
3964=== modified file 'glance/store/s3.py'
3965--- glance/store/s3.py 2011-01-27 04:19:13 +0000
3966+++ glance/store/s3.py 2011-07-26 10:13:35 +0000
3967@@ -17,7 +17,101 @@
3968
3969 """The s3 backend adapter"""
3970
3971+import urlparse
3972+
3973+from glance.common import exception
3974 import glance.store
3975+import glance.store.location
3976+
3977+glance.store.location.add_scheme_map({'s3': 's3',
3978+ 's3+http': 's3',
3979+ 's3+https': 's3'})
3980+
3981+
3982+class StoreLocation(glance.store.location.StoreLocation):
3983+
3984+ """
3985+ Class describing an S3 URI. An S3 URI can look like any of
3986+ the following:
3987+
3988+ s3://accesskey:secretkey@s3service.com/bucket/key-id
3989+ s3+http://accesskey:secretkey@s3service.com/bucket/key-id
3990+ s3+https://accesskey:secretkey@s3service.com/bucket/key-id
3991+
3992+ The s3+https:// URIs indicate there is an HTTPS s3service URL
3993+ """
3994+
3995+ def process_specs(self):
3996+ self.scheme = self.specs.get('scheme', 's3')
3997+ self.accesskey = self.specs.get('accesskey')
3998+ self.secretkey = self.specs.get('secretkey')
3999+ self.s3serviceurl = self.specs.get('s3serviceurl')
4000+ self.bucket = self.specs.get('bucket')
4001+ self.key = self.specs.get('key')
4002+
4003+ def _get_credstring(self):
4004+ if self.accesskey:
4005+ return '%s:%s@' % (self.accesskey, self.secretkey)
4006+ return ''
4007+
4008+ def get_uri(self):
4009+ return "%s://%s%s/%s/%s" % (
4010+ self.scheme,
4011+ self._get_credstring(),
4012+ self.s3serviceurl,
4013+ self.bucket,
4014+ self.key)
4015+
4016+ def parse_uri(self, uri):
4017+ """
4018+ Parse URLs. This method fixes an issue where credentials specified
4019+ in the URL are interpreted differently in Python 2.6.1+ than prior
4020+ versions of Python.
4021+
4022+ Note that an Amazon AWS secret key can contain the forward slash,
4023+ which is entirely retarded, and breaks urlparse miserably.
4024+ This function works around that issue.
4025+ """
4026+ pieces = urlparse.urlparse(uri)
4027+ assert pieces.scheme in ('s3', 's3+http', 's3+https')
4028+ self.scheme = pieces.scheme
4029+ path = pieces.path.strip('/')
4030+ netloc = pieces.netloc.strip('/')
4031+ entire_path = (netloc + '/' + path).strip('/')
4032+
4033+ if '@' in uri:
4034+ creds, path = entire_path.split('@')
4035+ cred_parts = creds.split(':')
4036+
4037+ try:
4038+ access_key = cred_parts[0]
4039+ secret_key = cred_parts[1]
4040+ # NOTE(jaypipes): Need to encode to UTF-8 here because of a
4041+ # bug in the HMAC library that boto uses.
4042+ # See: http://bugs.python.org/issue5285
4043+ # See: http://trac.edgewall.org/ticket/8083
4044+ access_key = access_key.encode('utf-8')
4045+ secret_key = secret_key.encode('utf-8')
4046+ self.accesskey = access_key
4047+ self.secretkey = secret_key
4048+ except IndexError:
4049+ reason = "Badly formed S3 credentials %s" % creds
4050+ raise exception.BadStoreUri(uri, reason)
4051+ else:
4052+ self.accesskey = None
4053+ path = entire_path
4054+ try:
4055+ path_parts = path.split('/')
4056+ self.key = path_parts.pop()
4057+ self.bucket = path_parts.pop()
4058+ if len(path_parts) > 0:
4059+ self.s3serviceurl = '/'.join(path_parts)
4060+ else:
4061+ reason = "Badly formed S3 URI. Missing s3 service URL."
4062+ raise exception.BadStoreUri(uri, reason)
4063+ except IndexError:
4064+ reason = "Badly formed S3 URI"
4065+ raise exception.BadStoreUri(uri, reason)
4066
4067
4068 class S3Backend(glance.store.Backend):
4069@@ -26,29 +120,30 @@
4070 EXAMPLE_URL = "s3://ACCESS_KEY:SECRET_KEY@s3_url/bucket/file.gz.0"
4071
4072 @classmethod
4073- def get(cls, parsed_uri, expected_size, conn_class=None):
4074- """
4075- Takes a parsed_uri in the format of:
4076- s3://access_key:secret_key@s3.amazonaws.com/bucket/file.gz.0, connects
4077- to s3 and downloads the file. Returns the generator resp_body provided
4078- by get_object.
4079- """
4080+ def get(cls, location, expected_size, conn_class=None):
4081+ """
4082+ Takes a `glance.store.location.Location` object that indicates
4083+ where to find the image file, and returns a generator from S3
4084+ provided by S3's key object
4085
4086+ :location `glance.store.location.Location` object, supplied
4087+ from glance.store.location.get_location_from_uri()
4088+ """
4089 if conn_class:
4090 pass
4091 else:
4092 import boto.s3.connection
4093 conn_class = boto.s3.connection.S3Connection
4094
4095- (access_key, secret_key, host, bucket, obj) = \
4096- cls._parse_s3_tokens(parsed_uri)
4097+ loc = location.store_location
4098
4099 # Close the connection when we're through.
4100- with conn_class(access_key, secret_key, host=host) as s3_conn:
4101- bucket = cls._get_bucket(s3_conn, bucket)
4102+ with conn_class(loc.accesskey, loc.secretkey,
4103+ host=loc.s3serviceurl) as s3_conn:
4104+ bucket = cls._get_bucket(s3_conn, loc.bucket)
4105
4106 # Close the key when we're through.
4107- with cls._get_key(bucket, obj) as key:
4108+ with cls._get_key(bucket, loc.obj) as key:
4109 if not key.size == expected_size:
4110 raise glance.store.BackendException(
4111 "Expected %s bytes, got %s" %
4112@@ -59,28 +154,28 @@
4113 yield chunk
4114
4115 @classmethod
4116- def delete(cls, parsed_uri, conn_class=None):
4117- """
4118- Takes a parsed_uri in the format of:
4119- s3://access_key:secret_key@s3.amazonaws.com/bucket/file.gz.0, connects
4120- to s3 and deletes the file. Returns whatever boto.s3.key.Key.delete()
4121- returns.
4122- """
4123+ def delete(cls, location, conn_class=None):
4124+ """
4125+ Takes a `glance.store.location.Location` object that indicates
4126+ where to find the image file to delete
4127
4128+ :location `glance.store.location.Location` object, supplied
4129+ from glance.store.location.get_location_from_uri()
4130+ """
4131 if conn_class:
4132 pass
4133 else:
4134 conn_class = boto.s3.connection.S3Connection
4135
4136- (access_key, secret_key, host, bucket, obj) = \
4137- cls._parse_s3_tokens(parsed_uri)
4138+ loc = location.store_location
4139
4140 # Close the connection when we're through.
4141- with conn_class(access_key, secret_key, host=host) as s3_conn:
4142- bucket = cls._get_bucket(s3_conn, bucket)
4143+ with conn_class(loc.accesskey, loc.secretkey,
4144+ host=loc.s3serviceurl) as s3_conn:
4145+ bucket = cls._get_bucket(s3_conn, loc.bucket)
4146
4147 # Close the key when we're through.
4148- with cls._get_key(bucket, obj) as key:
4149+ with cls._get_key(bucket, loc.obj) as key:
4150 return key.delete()
4151
4152 @classmethod
4153@@ -102,8 +197,3 @@
4154 if not key:
4155 raise glance.store.BackendException("Could not get key: %s" % key)
4156 return key
4157-
4158- @classmethod
4159- def _parse_s3_tokens(cls, parsed_uri):
4160- """Parse tokens from the parsed_uri"""
4161- return glance.store.parse_uri_tokens(parsed_uri, cls.EXAMPLE_URL)
4162
4163=== added file 'glance/store/scrubber.py'
4164--- glance/store/scrubber.py 1970-01-01 00:00:00 +0000
4165+++ glance/store/scrubber.py 2011-07-26 10:13:35 +0000
4166@@ -0,0 +1,90 @@
4167+# vim: tabstop=4 shiftwidth=4 softtabstop=4
4168+
4169+# Copyright 2010 OpenStack, LLC
4170+# All Rights Reserved.
4171+#
4172+# Licensed under the Apache License, Version 2.0 (the "License"); you may
4173+# not use this file except in compliance with the License. You may obtain
4174+# a copy of the License at
4175+#
4176+# http://www.apache.org/licenses/LICENSE-2.0
4177+#
4178+# Unless required by applicable law or agreed to in writing, software
4179+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
4180+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
4181+# License for the specific language governing permissions and limitations
4182+# under the License.
4183+
4184+import datetime
4185+import eventlet
4186+import logging
4187+
4188+from glance import registry
4189+from glance import store
4190+from glance.common import config
4191+from glance.common import context
4192+from glance.common import exception
4193+from glance.registry.db import api as db_api
4194+
4195+
4196+logger = logging.getLogger('glance.store.scrubber')
4197+
4198+
4199+class Daemon(object):
4200+ def __init__(self, wakeup_time=300, threads=1000):
4201+ logger.info("Starting Daemon: " +
4202+ "wakeup_time=%s threads=%s" % (wakeup_time, threads))
4203+ self.wakeup_time = wakeup_time
4204+ self.event = eventlet.event.Event()
4205+ self.pool = eventlet.greenpool.GreenPool(threads)
4206+
4207+ def start(self, application):
4208+ self._run(application)
4209+
4210+ def wait(self):
4211+ try:
4212+ self.event.wait()
4213+ except KeyboardInterrupt:
4214+ logger.info("Daemon Shutdown on KeyboardInterrupt")
4215+
4216+ def _run(self, application):
4217+ logger.debug("Runing application")
4218+ self.pool.spawn_n(application.run, self.pool, self.event)
4219+ eventlet.spawn_after(self.wakeup_time, self._run, application)
4220+ logger.debug("Next run scheduled in %s seconds" % self.wakeup_time)
4221+
4222+
4223+class Scrubber(object):
4224+ def __init__(self, options):
4225+ logger.info("Initializing scrubber with options: %s" % options)
4226+ self.options = options
4227+ scrub_time = config.get_option(options, 'scrub_time', type='int',
4228+ default=0)
4229+ logger.info("Scrub interval set to %s seconds" % scrub_time)
4230+ self.scrub_time = datetime.timedelta(seconds=scrub_time)
4231+ db_api.configure_db(options)
4232+
4233+ def run(self, pool, event=None):
4234+ delete_time = datetime.datetime.utcnow() - self.scrub_time
4235+ logger.info("Getting images deleted before %s" % delete_time)
4236+ pending = db_api.image_get_all_pending_delete(None, delete_time)
4237+ logger.info("Deleting %s images" % len(pending))
4238+ delete_work = [(p['id'], p['location']) for p in pending]
4239+ pool.starmap(self._delete, delete_work)
4240+
4241+ def _delete(self, id, location):
4242+ try:
4243+ logger.debug("Deleting %s" % location)
4244+ store.delete_from_backend(location)
4245+ except (store.UnsupportedBackend, exception.NotFound):
4246+ msg = "Failed to delete image from store (%s). "
4247+ logger.error(msg % uri)
4248+
4249+ ctx = context.RequestContext(is_admin=True, show_deleted=True)
4250+ db_api.image_update(ctx, id, {'status': 'deleted'})
4251+
4252+
4253+def app_factory(global_config, **local_conf):
4254+ conf = global_config.copy()
4255+ conf.update(local_conf)
4256+ return Scrubber(conf)
4257
4258=== modified file 'glance/store/swift.py'
4259--- glance/store/swift.py 2011-04-13 23:18:26 +0000
4260+++ glance/store/swift.py 2011-07-26 10:13:35 +0000
4261@@ -21,50 +21,150 @@
4262
4263 import httplib
4264 import logging
4265+import urlparse
4266
4267 from glance.common import config
4268 from glance.common import exception
4269 import glance.store
4270+import glance.store.location
4271
4272 DEFAULT_SWIFT_CONTAINER = 'glance'
4273
4274 logger = logging.getLogger('glance.store.swift')
4275
4276+glance.store.location.add_scheme_map({'swift': 'swift',
4277+ 'swift+http': 'swift',
4278+ 'swift+https': 'swift'})
4279+
4280+
4281+class StoreLocation(glance.store.location.StoreLocation):
4282+
4283+ """
4284+ Class describing a Swift URI. A Swift URI can look like any of
4285+ the following:
4286+
4287+ swift://user:pass@authurl.com/container/obj-id
4288+ swift+http://user:pass@authurl.com/container/obj-id
4289+ swift+https://user:pass@authurl.com/container/obj-id
4290+
4291+ The swift+https:// URIs indicate there is an HTTPS authentication URL
4292+ """
4293+
4294+ def process_specs(self):
4295+ self.scheme = self.specs.get('scheme', 'swift+https')
4296+ self.user = self.specs.get('user')
4297+ self.key = self.specs.get('key')
4298+ self.authurl = self.specs.get('authurl')
4299+ self.container = self.specs.get('container')
4300+ self.obj = self.specs.get('obj')
4301+
4302+ def _get_credstring(self):
4303+ if self.user:
4304+ return '%s:%s@' % (self.user, self.key)
4305+ return ''
4306+
4307+ def get_uri(self):
4308+ return "%s://%s%s/%s/%s" % (
4309+ self.scheme,
4310+ self._get_credstring(),
4311+ self.authurl,
4312+ self.container,
4313+ self.obj)
4314+
4315+ def parse_uri(self, uri):
4316+ """
4317+ Parse URLs. This method fixes an issue where credentials specified
4318+ in the URL are interpreted differently in Python 2.6.1+ than prior
4319+ versions of Python. It also deals with the peculiarity that new-style
4320+ Swift URIs have where a username can contain a ':', like so:
4321+
4322+ swift://account:user:pass@authurl.com/container/obj
4323+ """
4324+ pieces = urlparse.urlparse(uri)
4325+ assert pieces.scheme in ('swift', 'swift+http', 'swift+https')
4326+ self.scheme = pieces.scheme
4327+ netloc = pieces.netloc
4328+ path = pieces.path.lstrip('/')
4329+ if netloc != '':
4330+ # > Python 2.6.1
4331+ if '@' in netloc:
4332+ creds, netloc = netloc.split('@')
4333+ else:
4334+ creds = None
4335+ else:
4336+ # Python 2.6.1 compat
4337+ # see lp659445 and Python issue7904
4338+ if '@' in path:
4339+ creds, path = path.split('@')
4340+ else:
4341+ creds = None
4342+ netloc = path[0:path.find('/')].strip('/')
4343+ path = path[path.find('/'):].strip('/')
4344+ if creds:
4345+ cred_parts = creds.split(':')
4346+
4347+ # User can be account:user, in which case cred_parts[0:2] will be
4348+ # the account and user. Combine them into a single username of
4349+ # account:user
4350+ if len(cred_parts) == 1:
4351+ reason = "Badly formed credentials '%s' in Swift URI" % creds
4352+ raise exception.BadStoreUri(uri, reason)
4353+ elif len(cred_parts) == 3:
4354+ user = ':'.join(cred_parts[0:2])
4355+ else:
4356+ user = cred_parts[0]
4357+ key = cred_parts[-1]
4358+ self.user = user
4359+ self.key = key
4360+ else:
4361+ self.user = None
4362+ path_parts = path.split('/')
4363+ try:
4364+ self.obj = path_parts.pop()
4365+ self.container = path_parts.pop()
4366+ self.authurl = netloc
4367+ if len(path_parts) > 0:
4368+ self.authurl = netloc + '/' + '/'.join(path_parts).strip('/')
4369+ except IndexError:
4370+ reason = "Badly formed Swift URI"
4371+ raise exception.BadStoreUri(uri, reason)
4372+
4373
4374 class SwiftBackend(glance.store.Backend):
4375- """
4376- An implementation of the swift backend adapter.
4377- """
4378+ """An implementation of the swift backend adapter."""
4379+
4380 EXAMPLE_URL = "swift://<USER>:<KEY>@<AUTH_ADDRESS>/<CONTAINER>/<FILE>"
4381
4382 CHUNKSIZE = 65536
4383
4384 @classmethod
4385- def get(cls, parsed_uri, expected_size=None, options=None):
4386+ def get(cls, location, expected_size=None, options=None):
4387 """
4388- Takes a parsed_uri in the format of:
4389- swift://user:password@auth_url/container/file.gz.0, connects to the
4390- swift instance at auth_url and downloads the file. Returns the
4391- generator resp_body provided by get_object.
4392+ Takes a `glance.store.location.Location` object that indicates
4393+ where to find the image file, and returns a generator from Swift
4394+ provided by Swift client's get_object() method.
4395+
4396+ :location `glance.store.location.Location` object, supplied
4397+ from glance.store.location.get_location_from_uri()
4398 """
4399 from swift.common import client as swift_client
4400- (user, key, authurl, container, obj) = parse_swift_tokens(parsed_uri)
4401
4402 # TODO(sirp): snet=False for now, however, if the instance of
4403 # swift we're talking to is within our same region, we should set
4404 # snet=True
4405+ loc = location.store_location
4406 swift_conn = swift_client.Connection(
4407- authurl=authurl, user=user, key=key, snet=False)
4408+ authurl=loc.authurl, user=loc.user, key=loc.key, snet=False)
4409
4410 try:
4411 (resp_headers, resp_body) = swift_conn.get_object(
4412- container=container, obj=obj, resp_chunk_size=cls.CHUNKSIZE)
4413+ container=loc.container, obj=loc.obj,
4414+ resp_chunk_size=cls.CHUNKSIZE)
4415 except swift_client.ClientException, e:
4416 if e.http_status == httplib.NOT_FOUND:
4417- location = format_swift_location(user, key, authurl,
4418- container, obj)
4419+ uri = location.get_store_uri()
4420 raise exception.NotFound("Swift could not find image at "
4421- "location %(location)s" % locals())
4422+ "uri %(uri)s" % locals())
4423
4424 if expected_size:
4425 obj_size = int(resp_headers['content-length'])
4426@@ -99,6 +199,10 @@
4427 <CONTAINER> = ``swift_store_container``
4428 <ID> = The id of the image being added
4429
4430+ :note Swift auth URLs by default use HTTPS. To specify an HTTP
4431+ auth URL, you can specify http://someurl.com for the
4432+ swift_store_auth_address config option
4433+
4434 :param id: The opaque image identifier
4435 :param data: The image data to write, as a file-like object
4436 :param options: Conf mapping
4437@@ -120,9 +224,14 @@
4438 user = cls._option_get(options, 'swift_store_user')
4439 key = cls._option_get(options, 'swift_store_key')
4440
4441- full_auth_address = auth_address
4442- if not full_auth_address.startswith('http'):
4443- full_auth_address = 'https://' + full_auth_address
4444+ scheme = 'swift+https'
4445+ if auth_address.startswith('http://'):
4446+ scheme = 'swift+http'
4447+ full_auth_address = auth_address
4448+ elif auth_address.startswith('https://'):
4449+ full_auth_address = auth_address
4450+ else:
4451+ full_auth_address = 'https://' + auth_address # Defaults https
4452
4453 swift_conn = swift_client.Connection(
4454 authurl=full_auth_address, user=user, key=key, snet=False)
4455@@ -134,8 +243,13 @@
4456 create_container_if_missing(container, swift_conn, options)
4457
4458 obj_name = str(id)
4459- location = format_swift_location(user, key, auth_address,
4460- container, obj_name)
4461+ location = StoreLocation({'scheme': scheme,
4462+ 'container': container,
4463+ 'obj': obj_name,
4464+ 'authurl': auth_address,
4465+ 'user': user,
4466+ 'key': key})
4467+
4468 try:
4469 obj_etag = swift_conn.put_object(container, obj_name, data)
4470
4471@@ -153,7 +267,7 @@
4472 # header keys are lowercased by Swift
4473 if 'content-length' in resp_headers:
4474 size = int(resp_headers['content-length'])
4475- return (location, size, obj_etag)
4476+ return (location.get_uri(), size, obj_etag)
4477 except swift_client.ClientException, e:
4478 if e.http_status == httplib.CONFLICT:
4479 raise exception.Duplicate("Swift already has an image at "
4480@@ -163,89 +277,34 @@
4481 raise glance.store.BackendException(msg)
4482
4483 @classmethod
4484- def delete(cls, parsed_uri):
4485+ def delete(cls, location):
4486 """
4487- Deletes the swift object(s) at the parsed_uri location
4488+ Takes a `glance.store.location.Location` object that indicates
4489+ where to find the image file to delete
4490+
4491+ :location `glance.store.location.Location` object, supplied
4492+ from glance.store.location.get_location_from_uri()
4493 """
4494 from swift.common import client as swift_client
4495- (user, key, authurl, container, obj) = parse_swift_tokens(parsed_uri)
4496
4497 # TODO(sirp): snet=False for now, however, if the instance of
4498 # swift we're talking to is within our same region, we should set
4499 # snet=True
4500+ loc = location.store_location
4501 swift_conn = swift_client.Connection(
4502- authurl=authurl, user=user, key=key, snet=False)
4503+ authurl=loc.authurl, user=loc.user, key=loc.key, snet=False)
4504
4505 try:
4506- swift_conn.delete_object(container, obj)
4507+ swift_conn.delete_object(loc.container, loc.obj)
4508 except swift_client.ClientException, e:
4509 if e.http_status == httplib.NOT_FOUND:
4510- location = format_swift_location(user, key, authurl,
4511- container, obj)
4512+ uri = location.get_store_uri()
4513 raise exception.NotFound("Swift could not find image at "
4514- "location %(location)s" % locals())
4515+ "uri %(uri)s" % locals())
4516 else:
4517 raise
4518
4519
4520-def parse_swift_tokens(parsed_uri):
4521- """
4522- Return the various tokens used by Swift.
4523-
4524- :param parsed_uri: The pieces of a URI returned by urlparse
4525- :retval A tuple of (user, key, auth_address, container, obj_name)
4526- """
4527- path = parsed_uri.path.lstrip('//')
4528- netloc = parsed_uri.netloc
4529-
4530- try:
4531- try:
4532- creds, netloc = netloc.split('@')
4533- path = '/'.join([netloc, path])
4534- except ValueError:
4535- # Python 2.6.1 compat
4536- # see lp659445 and Python issue7904
4537- creds, path = path.split('@')
4538-
4539- cred_parts = creds.split(':')
4540-
4541- # User can be account:user, in which case cred_parts[0:2] will be
4542- # the account and user. Combine them into a single username of
4543- # account:user
4544- if len(cred_parts) == 3:
4545- user = ':'.join(cred_parts[0:2])
4546- else:
4547- user = cred_parts[0]
4548- key = cred_parts[-1]
4549- path_parts = path.split('/')
4550- obj = path_parts.pop()
4551- container = path_parts.pop()
4552- except (ValueError, IndexError):
4553- raise glance.store.BackendException(
4554- "Expected four values to unpack in: swift:%s. "
4555- "Should have received something like: %s."
4556- % (parsed_uri.path, SwiftBackend.EXAMPLE_URL))
4557-
4558- authurl = "https://%s" % '/'.join(path_parts)
4559-
4560- return user, key, authurl, container, obj
4561-
4562-
4563-def format_swift_location(user, key, auth_address, container, obj_name):
4564- """
4565- Returns the swift URI in the format:
4566- swift://<USER>:<KEY>@<AUTH_ADDRESS>/<CONTAINER>/<OBJNAME>
4567-
4568- :param user: The swift user to authenticate with
4569- :param key: The auth key for the authenticating user
4570- :param auth_address: The base URL for the authentication service
4571- :param container: The name of the container
4572- :param obj_name: The name of the object
4573- """
4574- return "swift://%(user)s:%(key)s@%(auth_address)s/"\
4575- "%(container)s/%(obj_name)s" % locals()
4576-
4577-
4578 def create_container_if_missing(container, swift_conn, options):
4579 """
4580 Creates a missing container in Swift if the
4581
4582=== modified file 'glance/utils.py'
4583--- glance/utils.py 2011-06-11 18:18:03 +0000
4584+++ glance/utils.py 2011-07-26 10:13:35 +0000
4585@@ -18,6 +18,12 @@
4586 """
4587 A few utility routines used throughout Glance
4588 """
4589+import errno
4590+import logging
4591+
4592+import xattr
4593+
4594+logger = logging.getLogger('glance.utils')
4595
4596
4597 def image_meta_to_http_headers(image_meta):
4598@@ -99,3 +105,158 @@
4599 :param req: Webob.Request object
4600 """
4601 return req.content_length or 'transfer-encoding' in req.headers
4602+
4603+
4604+def chunkiter(fp, chunk_size=65536):
4605+ """Return an iterator to a file-like obj which yields fixed size chunks
4606+
4607+ :param fp: a file-like object
4608+ :param chunk_size: maximum size of chunk
4609+ """
4610+ while True:
4611+ chunk = fp.read(chunk_size)
4612+ if chunk:
4613+ yield chunk
4614+ else:
4615+ break
4616+
4617+
4618+class PrettyTable(object):
4619+ """Creates an ASCII art table for use in bin/glance
4620+
4621+ Example:
4622+
4623+ ID Name Size Hits
4624+ --- ----------------- ------------ -----
4625+ 122 image 22 0
4626+ """
4627+ def __init__(self):
4628+ self.columns = []
4629+
4630+ def add_column(self, width, label="", just='l'):
4631+ """Add a column to the table
4632+
4633+ :param width: number of characters wide the column should be
4634+ :param label: column heading
4635+ :param just: justification for the column, 'l' for left,
4636+ 'r' for right
4637+ """
4638+ self.columns.append((width, label, just))
4639+
4640+ def make_header(self):
4641+ label_parts = []
4642+ break_parts = []
4643+ for width, label, _ in self.columns:
4644+ # NOTE(sirp): headers are always left justified
4645+ label_part = self._clip_and_justify(label, width, 'l')
4646+ label_parts.append(label_part)
4647+
4648+ break_part = '-' * width
4649+ break_parts.append(break_part)
4650+
4651+ label_line = ' '.join(label_parts)
4652+ break_line = ' '.join(break_parts)
4653+ return '\n'.join([label_line, break_line])
4654+
4655+ def make_row(self, *args):
4656+ row = args
4657+ row_parts = []
4658+ for data, (width, _, just) in zip(row, self.columns):
4659+ row_part = self._clip_and_justify(data, width, just)
4660+ row_parts.append(row_part)
4661+
4662+ row_line = ' '.join(row_parts)
4663+ return row_line
4664+
4665+ @staticmethod
4666+ def _clip_and_justify(data, width, just):
4667+ # clip field to column width
4668+ clipped_data = str(data)[:width]
4669+
4670+ if just == 'r':
4671+ # right justify
4672+ justified = clipped_data.rjust(width)
4673+ else:
4674+ # left justify
4675+ justified = clipped_data.ljust(width)
4676+
4677+ return justified
4678+
4679+
4680+def _make_namespaced_xattr_key(key, namespace='user'):
4681+ """Create a fully-qualified xattr-key by including the intended namespace.
4682+
4683+ Namespacing differs among OSes[1]:
4684+
4685+ FreeBSD: user, system
4686+ Linux: user, system, trusted, security
4687+ MacOS X: not needed
4688+
4689+ Mac OS X won't break if we include a namespace qualifier, so, for
4690+ simplicity, we always include it.
4691+
4692+ --
4693+ [1] http://en.wikipedia.org/wiki/Extended_file_attributes
4694+ """
4695+ namespaced_key = ".".join([namespace, key])
4696+ return namespaced_key
4697+
4698+
4699+def get_xattr(path, key, **kwargs):
4700+ """Return the value for a particular xattr
4701+
4702+ If the key doesn't not exist, or xattrs aren't supported by the file
4703+ system then a KeyError will be raised, that is, unless you specify a
4704+ default using kwargs.
4705+ """
4706+ namespaced_key = _make_namespaced_xattr_key(key)
4707+ entry_xattr = xattr.xattr(path)
4708+ try:
4709+ return entry_xattr[namespaced_key]
4710+ except KeyError:
4711+ if 'default' in kwargs:
4712+ return kwargs['default']
4713+ else:
4714+ raise
4715+
4716+
4717+def set_xattr(path, key, value):
4718+ """Set the value of a specified xattr.
4719+
4720+ If xattrs aren't supported by the file-system, we skip setting the value.
4721+ """
4722+ namespaced_key = _make_namespaced_xattr_key(key)
4723+ entry_xattr = xattr.xattr(path)
4724+ try:
4725+ entry_xattr.set(namespaced_key, str(value))
4726+ except IOError as e:
4727+ if e.errno == errno.EOPNOTSUPP:
4728+ logger.warn("xattrs not supported, skipping...")
4729+ else:
4730+ raise
4731+
4732+
4733+def inc_xattr(path, key, n=1):
4734+ """Increment the value of an xattr (assuming it is an integer).
4735+
4736+ BEWARE, this code *does* have a RACE CONDITION, since the
4737+ read/update/write sequence is not atomic.
4738+
4739+ Since the use-case for this function is collecting stats--not critical--
4740+ the benefits of simple, lock-free code out-weighs the possibility of an
4741+ occasional hit not being counted.
4742+ """
4743+ try:
4744+ count = int(get_xattr(path, key))
4745+ except KeyError:
4746+ # NOTE(sirp): a KeyError is generated in two cases:
4747+ # 1) xattrs is not supported by the filesystem
4748+ # 2) the key is not present on the file
4749+ #
4750+ # In either case, just ignore it...
4751+ pass
4752+ else:
4753+ # NOTE(sirp): only try to bump the count if xattrs is supported
4754+ # and the key is present
4755+ count += n
4756+ set_xattr(path, key, str(count))
4757
4758=== modified file 'run_tests.py'
4759--- run_tests.py 2011-06-24 18:44:34 +0000
4760+++ run_tests.py 2011-07-26 10:13:35 +0000
4761@@ -38,7 +38,8 @@
4762 # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
4763 # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
4764
4765-"""Unittest runner for glance
4766+"""
4767+Unittest runner for glance
4768
4769 To run all test::
4770 python run_tests.py
4771@@ -179,7 +180,8 @@
4772 self._last_case = None
4773 self.colorizer = None
4774 # NOTE(vish, tfukushima): reset stdout for the terminal check
4775- stdout = sys.__stdout__
4776+ stdout = sys.stdout
4777+ sys.stdout = sys.__stdout__
4778 for colorizer in [_Win32Colorizer, _AnsiColorizer, _NullColorizer]:
4779 if colorizer.supported():
4780 self.colorizer = colorizer(self.stream)
4781@@ -211,7 +213,8 @@
4782
4783 # NOTE(vish, tfukushima): copied from unittest with edit to add color
4784 def addError(self, test, err):
4785- """Overrides normal addError to add support for errorClasses.
4786+ """
4787+ Overrides normal addError to add support for errorClasses.
4788 If the exception is a registered class, the error will be added
4789 to the list for that class, not errors.
4790 """
4791@@ -279,7 +282,8 @@
4792
4793 c = config.Config(stream=sys.stdout,
4794 env=os.environ,
4795- verbosity=3)
4796+ verbosity=3,
4797+ plugins=core.DefaultPluginManager())
4798
4799 runner = GlanceTestRunner(stream=c.stream,
4800 verbosity=c.verbosity,
4801
4802=== modified file 'setup.py'
4803--- setup.py 2011-04-15 09:34:17 +0000
4804+++ setup.py 2011-07-26 10:13:35 +0000
4805@@ -87,6 +87,9 @@
4806 ],
4807 scripts=['bin/glance',
4808 'bin/glance-api',
4809+ 'bin/glance-cache-prefetcher',
4810+ 'bin/glance-cache-pruner',
4811+ 'bin/glance-cache-reaper',
4812 'bin/glance-control',
4813 'bin/glance-manage',
4814 'bin/glance-registry',
4815
4816=== modified file 'tests/functional/__init__.py'
4817--- tests/functional/__init__.py 2011-05-27 02:24:55 +0000
4818+++ tests/functional/__init__.py 2011-07-26 10:13:35 +0000
4819@@ -127,7 +127,7 @@
4820 Server object that starts/stops/manages the API server
4821 """
4822
4823- def __init__(self, test_dir, port, registry_port):
4824+ def __init__(self, test_dir, port, registry_port, delayed_delete=False):
4825 super(ApiServer, self).__init__(test_dir, port)
4826 self.server_name = 'api'
4827 self.default_store = 'file'
4828@@ -137,6 +137,7 @@
4829 "api.pid")
4830 self.log_file = os.path.join(self.test_dir, "api.log")
4831 self.registry_port = registry_port
4832+ self.delayed_delete = delayed_delete
4833 self.conf_base = """[DEFAULT]
4834 verbose = %(verbose)s
4835 debug = %(debug)s
4836@@ -147,9 +148,10 @@
4837 registry_host = 0.0.0.0
4838 registry_port = %(registry_port)s
4839 log_file = %(log_file)s
4840+delayed_delete = %(delayed_delete)s
4841
4842 [pipeline:glance-api]
4843-pipeline = versionnegotiation apiv1app
4844+pipeline = versionnegotiation context apiv1app
4845
4846 [pipeline:versions]
4847 pipeline = versionsapp
4848@@ -162,6 +164,9 @@
4849
4850 [filter:versionnegotiation]
4851 paste.filter_factory = glance.api.middleware.version_negotiation:filter_factory
4852+
4853+[filter:context]
4854+paste.filter_factory = glance.common.context:filter_factory
4855 """
4856
4857
4858@@ -175,11 +180,7 @@
4859 super(RegistryServer, self).__init__(test_dir, port)
4860 self.server_name = 'registry'
4861
4862- # NOTE(sirp): in-memory DBs don't play well with sqlalchemy migrate
4863- # (see http://code.google.com/p/sqlalchemy-migrate/
4864- # issues/detail?id=72)
4865- self.db_file = os.path.join(self.test_dir, 'test_glance_api.sqlite')
4866- default_sql_connection = 'sqlite:///%s' % self.db_file
4867+ default_sql_connection = 'sqlite:///'
4868 self.sql_connection = os.environ.get('GLANCE_TEST_SQL_CONNECTION',
4869 default_sql_connection)
4870
4871@@ -195,8 +196,44 @@
4872 sql_connection = %(sql_connection)s
4873 sql_idle_timeout = 3600
4874
4875-[app:glance-registry]
4876+[pipeline:glance-registry]
4877+pipeline = context registryapp
4878+
4879+[app:registryapp]
4880 paste.app_factory = glance.registry.server:app_factory
4881+
4882+[filter:context]
4883+paste.filter_factory = glance.common.context:filter_factory
4884+"""
4885+
4886+
4887+class ScrubberDaemon(Server):
4888+ """
4889+ Server object that starts/stops/manages the Scrubber server
4890+ """
4891+
4892+ def __init__(self, test_dir, sql_connection, daemon=False):
4893+ # NOTE(jkoelker): Set the port to 0 since we actually don't listen
4894+ super(ScrubberDaemon, self).__init__(test_dir, 0)
4895+ self.server_name = 'scrubber'
4896+ self.daemon = daemon
4897+
4898+ self.sql_connection = sql_connection
4899+
4900+ self.pid_file = os.path.join(self.test_dir, "scrubber.pid")
4901+ self.log_file = os.path.join(self.test_dir, "scrubber.log")
4902+ self.conf_base = """[DEFAULT]
4903+verbose = %(verbose)s
4904+debug = %(debug)s
4905+log_file = %(log_file)s
4906+scrub_time = 5
4907+daemon = %(daemon)s
4908+wakeup_time = 2
4909+sql_connection = %(sql_connection)s
4910+sql_idle_timeout = 3600
4911+
4912+[app:glance-scrubber]
4913+paste.app_factory = glance.store.scrubber:app_factory
4914 """
4915
4916
4917@@ -221,8 +258,13 @@
4918 self.registry_server = RegistryServer(self.test_dir,
4919 self.registry_port)
4920
4921+ registry_db = self.registry_server.sql_connection
4922+ self.scrubber_daemon = ScrubberDaemon(self.test_dir,
4923+ sql_connection=registry_db)
4924+
4925 self.pid_files = [self.api_server.pid_file,
4926- self.registry_server.pid_file]
4927+ self.registry_server.pid_file,
4928+ self.scrubber_daemon.pid_file]
4929 self.files_to_destroy = []
4930
4931 def tearDown(self):
4932@@ -308,6 +350,13 @@
4933 "Got: %s" % err)
4934 self.assertTrue("Starting glance-registry with" in out)
4935
4936+ exitcode, out, err = self.scrubber_daemon.start(**kwargs)
4937+
4938+ self.assertEqual(0, exitcode,
4939+ "Failed to spin up the Scrubber daemon. "
4940+ "Got: %s" % err)
4941+ self.assertTrue("Starting glance-scrubber with" in out)
4942+
4943 self.wait_for_servers()
4944
4945 def ping_server(self, port):
4946@@ -365,6 +414,10 @@
4947 "Failed to spin down the Registry server. "
4948 "Got: %s" % err)
4949
4950+ exitcode, out, err = self.scrubber_daemon.stop()
4951+ self.assertEqual(0, exitcode,
4952+ "Failed to spin down the Scrubber daemon. "
4953+ "Got: %s" % err)
4954 # If all went well, then just remove the test directory.
4955 # We only want to check the logs and stuff if something
4956 # went wrong...
4957
4958=== modified file 'tests/functional/test_bin_glance.py'
4959--- tests/functional/test_bin_glance.py 2011-05-05 23:12:21 +0000
4960+++ tests/functional/test_bin_glance.py 2011-07-26 10:13:35 +0000
4961@@ -40,7 +40,6 @@
4962 3. Delete the image
4963 4. Verify no longer in index
4964 """
4965-
4966 self.cleanup()
4967 self.start_servers()
4968
4969@@ -106,7 +105,6 @@
4970 5. Update the image's Name attribute
4971 6. Verify the updated name is shown
4972 """
4973-
4974 self.cleanup()
4975 self.start_servers()
4976
4977@@ -192,7 +190,6 @@
4978 3. Verify the status of the image is displayed in the show output
4979 and is in status 'killed'
4980 """
4981-
4982 self.cleanup()
4983
4984 # Start servers with a Swift backend and a bad auth URL
4985@@ -253,7 +250,6 @@
4986 3. Verify no public images found
4987 4. Run SQL against DB to verify no undeleted properties
4988 """
4989-
4990 self.cleanup()
4991 self.start_servers()
4992
4993
4994=== modified file 'tests/functional/test_curl_api.py'
4995--- tests/functional/test_curl_api.py 2011-06-26 19:57:51 +0000
4996+++ tests/functional/test_curl_api.py 2011-07-26 10:13:35 +0000
4997@@ -63,7 +63,6 @@
4998 11. PUT /images/1
4999 - Add a previously deleted property.
5000 """
The diff has been truncated for viewing.

Subscribers

People subscribed via source and target branches