Merge lp:~rconradharris/glance/cached-images-middleware into lp:~hudson-openstack/glance/trunk

Proposed by Jay Pipes
Status: Merged
Approved by: Jay Pipes
Approved revision: 229
Merged at revision: 160
Proposed branch: lp:~rconradharris/glance/cached-images-middleware
Merge into: lp:~hudson-openstack/glance/trunk
Diff against target: 2319 lines (+1921/-99)
21 files modified
bin/glance (+372/-59)
bin/glance-cache-prefetcher (+65/-0)
bin/glance-cache-pruner (+65/-0)
bin/glance-cache-reaper (+73/-0)
etc/glance-api.conf (+16/-0)
etc/glance-prefetcher.conf (+21/-0)
etc/glance-pruner.conf (+28/-0)
etc/glance-reaper.conf (+29/-0)
glance/api/__init__.py (+47/-0)
glance/api/cached_images.py (+105/-0)
glance/api/middleware/image_cache.py (+57/-0)
glance/api/v1/__init__.py (+1/-1)
glance/api/v1/images.py (+61/-39)
glance/client.py (+115/-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/utils.py (+161/-0)
setup.py (+3/-0)
tools/pip-requires (+1/-0)
To merge this branch: bzr merge lp:~rconradharris/glance/cached-images-middleware
Reviewer Review Type Date Requested Status
Brian Waldon (community) Approve
Ed Leafe (community) Approve
Jay Pipes (community) Approve
Review via email: mp+69126@code.launchpad.net

Description of the change

New local filesystem image cache with REST managment API

Additional changes include:

    * Added glance-reaper binary
    * Moved invalid cache entry reaping from glance-pruner to glance-reaper
    * Added stalled image detection and reaping
    * Added incomplete image listing (shows which images are currently being fetched)
    * Added percent progress to incomplete image listing and invalid image listing (useful when looking for failure trends)
    * Added ability to reap-invalid and reap-stalled directly from glance command. Helpful for testing and recovering from failures immediately (w/o having to wait for cron to kick off glance-reaper)
    * Renamed glance cache management commands. Now all start with 'cache-' and they better mirror the naming of 'image' commands
    * Fixed bug where print_help didn't have access to parser to print usage

To post a comment you must log in.
Revision history for this message
Ed Leafe (ed-leafe) wrote :

Haven't gone through all the code, but I noticed several places where str(e) is used. Since error strings can be localized, it is reasonable to expect non-ASCII characters to appear in them. Either unicode(e) or "%s" % e should be used instead.

Revision history for this message
Ed Leafe (ed-leafe) wrote :

Another observation:
In the open() method of the ImageCache class, you test for the presence of 'r' or 'w' in the mode param. This means that any mode that contains either of the two characters is valid, and if it contains both, then 'w' is assumed. Should this be more discriminating? e.g.:

mode = mode[0].lower()
if mode == 'w':
    ...
elif mode == 'r':
    ....
else:
    raise ...

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

Thanks for the comments, Ed.

> Haven't gone through all the code, but I noticed several places where str(e)
> is used. Since error strings can be localized, it is reasonable to expect
> non-ASCII characters to appear in them. Either unicode(e) or "%s" % e should
> be used instead.

Looking around, I see a few other places where we do `str(e)` as well. I'm
hesitant to fix those at the moment (perhaps those should be a cleanup patch?),
but in this case, I don't see anything wrong with using one of the forms
you've suggested.

Look for a fix shortly.

> In the open() method of the ImageCache class, you test for the presence of 'r' or 'w' in the mode param. This means that any mode that contains either of the two characters is valid, and if it contains both, then 'w' is assumed. Should this be more discriminating? e.g.:

Nice catch, if the file was opened for `rw`, it would correctly call the
`_open_write` handler, but it wouldn't bump the hit-count.

`rw` mode isn't really used (nor should it since image-data is immutable), so,
I think it's safe to "tighten" up the switch statement to be something like:

    if mode == 'r': do_read
    elif mode == 'w': do_write
    else: raise not supported

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

Awesome work, Rick. Looking forward to getting this into trunk...

Tiny nits:

1161 + logger.debug("image cache HIT, retrieving image '%s'"
1162 + " from cache" % id)

There are a couple places where you're doing the above. Should really be:

logger.debug("image cache HIT, retrieving image '%s' from cache", id)

Note the use of args to debug() instead of % interpolation...

1569 + def _delete_file(path):
1570 + if os.path.exists(path):
1571 + logger.debug("deleting image cache file '%s'", path)
1572 + os.unlink(path)

Might want to add an else: block there and log the error if trying to delete a non-existing image. This might be useful for identifying if any race conditions are occurring...

Finally, it would be good to add some documentation for the cache and its management API. I'm OK with doing this documentation in a follow-up bug in D4.

-jay

review: Needs Fixing
Revision history for this message
Ed Leafe (ed-leafe) wrote :

> Looking around, I see a few other places where we do `str(e)` as well.

Heh, this isn't the first time I've made a comment like this about OS code. :)

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

> > Looking around, I see a few other places where we do `str(e)` as well.
>
> Heh, this isn't the first time I've made a comment like this about OS code. :)

FYI, I told Rick we can do a separate refactoring patch around this. I've logged a bug about it for tracking purposes: https://bugs.launchpad.net/glance/+bug/816077

-jay

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

Ed:

Per Jay's comments, going to hold of on the `str(e)` issue.

Fixed the loose file mode handling.

Jay:

> Note the use of args to debug() instead of % interpolation...

Fixed.

> Might want to add an else: block there and log the error if trying to delete a non-existing image.
> This might be useful for identifying if any race conditions are occurring...

Good call, fixed.

> Finally, it would be good to add some documentation for the cache and its management API. I'm OK with doing this documentation in a follow-up bug in D4.

Agreed.

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

Rock and roll.

review: Approve
Revision history for this message
Ed Leafe (ed-leafe) wrote :

Jay:
> FYI, I told Rick we can do a separate refactoring patch around this.
> I've logged a bug about it for tracking purposes:
> https://bugs.launchpad.net/glance/+bug/816077

Cool; probably a better way to handle this.

Rick:
> Fixed the loose file mode handling.

Looks good. Finally got a chance to look over the rest of the code, and it looks good, too.

review: Approve
Revision history for this message
Brian Waldon (bcwaldon) wrote :

1) The decision was made to implement the caching layer as an extension, so in the future it will be easy to replace it with an external service. An optional middleware is a good way to accomplish this without tarnishing the rest of our API code, but I'm curious why we didn't take a similar approach with the command-line utility? Wouldn't it make sense to separate that out (possibly into a separate binary) so it can be completely removed later on? I always thought the cli tool (bin/glance) was analogous to the user-facing API, and caching commands shouldn't live there.

2) The API looks much better, but I think reap_invalid and reap_stalled still need to be changed. Would DELETE with a status query param suffice?

DELETE /cached_images?status=invalid
DELETE /cached_images?status=incomplete

3) Can the responsibilities assumed by cache-reaper and cache-pruner be consolidated into a single file?

review: Needs Information
Revision history for this message
Rick Harris (rconradharris) wrote :

> 1) The decision was made to implement the caching layer as an extension, so in
> the future it will be easy to replace it with an external service. An optional
> middleware is a good way to accomplish this without tarnishing the rest of our
> API code, but I'm curious why we didn't take a similar approach with the
> command-line utility? Wouldn't it make sense to separate that out (possibly
> into a separate binary) so it can be completely removed later on? I always
> thought the cli tool (bin/glance) was analogous to the user-facing API, and
> caching commands shouldn't live there.

Interesting point. There is some precedent already in Nova to having the CLI
tool also handle admin-only functionality (in Nova, scheduled backups are
kicked off by the novaclient tool).

I followed the same model for glance-caching. As to whether this is the right
approach, I'm not sure. It's convenient to have a single binary for working
with glance, but I can see the benefit of having it split out, too.

Either way, I think we should probably get an ML discussion going where we can
discuss this not just for Glance, but also for Nova as well. Whatever we come
up with could be addressed in a follow-on patch.

> 2) The API looks much better, but I think reap_invalid and reap_stalled still
> need to be changed. Would DELETE with a status query param suffice?
>
> DELETE /cached_images?status=invalid DELETE /cached_images?status=incomplete

Fixed.

> 3) Can the responsibilities assumed by cache-reaper and cache-pruner be
> consolidated into a single file?

I see pruning (part of normal operation) and reaping (recovering from error
cases) as different functionality. I'd prefer to keep them as separate
binaries, with separate source files, and their own log files.

(Aside: we should expect lots of output in the pruning.log; however, if we're
seeing lots of output in reaping.log, we have some bugs we need to hammer
out).

Revision history for this message
Brian Waldon (bcwaldon) wrote :

> > 1) The decision was made to implement the caching layer as an extension, so
> in
> > the future it will be easy to replace it with an external service. An
> optional
> > middleware is a good way to accomplish this without tarnishing the rest of
> our
> > API code, but I'm curious why we didn't take a similar approach with the
> > command-line utility? Wouldn't it make sense to separate that out (possibly
> > into a separate binary) so it can be completely removed later on? I always
> > thought the cli tool (bin/glance) was analogous to the user-facing API, and
> > caching commands shouldn't live there.
>
> Interesting point. There is some precedent already in Nova to having the CLI
> tool also handle admin-only functionality (in Nova, scheduled backups are
> kicked off by the novaclient tool).

Well this isn't an admin-only feature, it is supposed to be an 'extension'. I still see those as different things. Keep in mind that novaclient is not being developed under the Nova project within OpenStack, either.

> I followed the same model for glance-caching. As to whether this is the right
> approach, I'm not sure. It's convenient to have a single binary for working
> with glance, but I can see the benefit of having it split out, too.
>
> Either way, I think we should probably get an ML discussion going where we can
> discuss this not just for Glance, but also for Nova as well. Whatever we come
> up with could be addressed in a follow-on patch.

I agree with a future decision here.

> > 3) Can the responsibilities assumed by cache-reaper and cache-pruner be
> > consolidated into a single file?
>
> I see pruning (part of normal operation) and reaping (recovering from error
> cases) as different functionality. I'd prefer to keep them as separate
> binaries, with separate source files, and their own log files.

I still see pruning/reaping as the same thing, just with two different names. They both remove images from the cache based on a set of criteria.

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

Brian, I see both of your concerns as things that can possibly be addressed in future patches. Would you be OK with seeing this patch go through?

Revision history for this message
Brian Waldon (bcwaldon) wrote :

Certainly :)

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'bin/glance'
2--- bin/glance 2011-06-28 14:17:58 +0000
3+++ bin/glance 2011-07-25 22:03:31 +0000
4@@ -22,6 +22,7 @@
5 stored in one or more Glance nodes.
6 """
7
8+import functools
9 import optparse
10 import os
11 import re
12@@ -36,15 +37,45 @@
13 if os.path.exists(os.path.join(possible_topdir, 'glance', '__init__.py')):
14 sys.path.insert(0, possible_topdir)
15
16-from glance import client
17+from glance import client as glance_client
18 from glance import version
19 from glance.common import exception
20-from glance.common import utils
21+from glance.common import utils as common_utils
22+from glance import utils
23
24 SUCCESS = 0
25 FAILURE = 1
26
27
28+#TODO(sirp): make more of the actions use this decorator
29+def catch_error(action):
30+ """Decorator to provide sensible default error handling for actions."""
31+ def wrap(func):
32+ @functools.wraps(func)
33+ def wrapper(*args, **kwargs):
34+ try:
35+ ret = func(*args, **kwargs)
36+ return SUCCESS if ret is None else ret
37+ except Exception, e:
38+ print "Failed to %s. Got error:" % action
39+ pieces = str(e).split('\n')
40+ for piece in pieces:
41+ print piece
42+ return FAILURE
43+
44+ return wrapper
45+ return wrap
46+
47+
48+def get_percent_done(image):
49+ try:
50+ pct_done = image['size'] * 100 / int(image['expected_size'])
51+ except (ValueError, ZeroDivisionError):
52+ # NOTE(sirp): Ignore if expected_size isn't a number, or if it's 0
53+ pct_done = "UNKNOWN"
54+ return pct_done
55+
56+
57 def get_image_fields_from_args(args):
58 """
59 Validate the set of arguments passed as field name/value pairs
60@@ -152,7 +183,7 @@
61 return FAILURE
62
63 image_meta = {'name': fields.pop('name'),
64- 'is_public': utils.bool_from_string(
65+ 'is_public': common_utils.bool_from_string(
66 fields.pop('is_public', False)),
67 'disk_format': fields.pop('disk_format', 'raw'),
68 'container_format': fields.pop('container_format', 'ovf')}
69@@ -276,7 +307,7 @@
70
71 # Have to handle "boolean" values specially...
72 if 'is_public' in fields:
73- image_meta['is_public'] = utils.bool_from_string(
74+ image_meta['is_public'] = common_utils.bool_from_string(
75 fields.pop('is_public'))
76
77 # Add custom attributes, which are all the arguments remaining
78@@ -364,40 +395,37 @@
79 return FAILURE
80
81
82+@catch_error('show index')
83 def images_index(options, args):
84 """
85 %(prog)s index [options]
86
87 Returns basic information for all public images
88 a Glance server knows about"""
89- c = get_client(options)
90- try:
91- images = c.get_images()
92- if len(images) == 0:
93- print "No public images found."
94- return SUCCESS
95-
96- print "Found %d public images..." % len(images)
97- print "%-16s %-30s %-20s %-20s %-14s" % (("ID"),
98- ("Name"),
99- ("Disk Format"),
100- ("Container Format"),
101- ("Size"))
102- print ('-' * 16) + " " + ('-' * 30) + " "\
103- + ('-' * 20) + " " + ('-' * 20) + " " + ('-' * 14)
104- for image in images:
105- print "%-16s %-30s %-20s %-20s %14d" % (image['id'],
106- image['name'],
107- image['disk_format'],
108- image['container_format'],
109- int(image['size']))
110+ client = get_client(options)
111+ images = client.get_images()
112+ if not images:
113+ print "No public images found."
114 return SUCCESS
115- except Exception, e:
116- print "Failed to show index. Got error:"
117- pieces = str(e).split('\n')
118- for piece in pieces:
119- print piece
120- return FAILURE
121+
122+ print "Found %d public images..." % len(images)
123+
124+ pretty_table = utils.PrettyTable()
125+ pretty_table.add_column(16, label="ID")
126+ pretty_table.add_column(30, label="Name")
127+ pretty_table.add_column(20, label="Disk Format")
128+ pretty_table.add_column(20, label="Container Format")
129+ pretty_table.add_column(14, label="Size", just="r")
130+
131+ print pretty_table.make_header()
132+
133+ for image in images:
134+ print pretty_table.make_row(
135+ image['id'],
136+ image['name'],
137+ image['disk_format'],
138+ image['container_format'],
139+ image['size'])
140
141
142 def images_detailed(options, args):
143@@ -458,13 +486,256 @@
144 return SUCCESS
145
146
147+@catch_error('show cached images')
148+def cache_index(options, args):
149+ """
150+%(prog)s cache-index [options]
151+
152+List all images currently cached"""
153+ client = get_client(options)
154+ images = client.get_cached_images()
155+ if not images:
156+ print "No cached images."
157+ return SUCCESS
158+
159+ print "Found %d cached images..." % len(images)
160+
161+ pretty_table = utils.PrettyTable()
162+ pretty_table.add_column(16, label="ID")
163+ pretty_table.add_column(30, label="Name")
164+ pretty_table.add_column(19, label="Last Accessed (UTC)")
165+ # 1 TB takes 13 characters to display: len(str(2**40)) == 13
166+ pretty_table.add_column(14, label="Size", just="r")
167+ pretty_table.add_column(10, label="Hits", just="r")
168+
169+ print pretty_table.make_header()
170+
171+ for image in images:
172+ print pretty_table.make_row(
173+ image['id'],
174+ image['name'],
175+ image['last_accessed'],
176+ image['size'],
177+ image['hits'])
178+
179+
180+@catch_error('show invalid cache images')
181+def cache_invalid(options, args):
182+ """
183+%(prog)s cache-invalid [options]
184+
185+List current invalid cache images"""
186+ client = get_client(options)
187+ images = client.get_invalid_cached_images()
188+ if not images:
189+ print "No invalid cached images."
190+ return SUCCESS
191+
192+ print "Found %d invalid cached images..." % len(images)
193+
194+ pretty_table = utils.PrettyTable()
195+ pretty_table.add_column(16, label="ID")
196+ pretty_table.add_column(30, label="Name")
197+ pretty_table.add_column(30, label="Error")
198+ pretty_table.add_column(19, label="Last Modified (UTC)")
199+ # 1 TB takes 13 characters to display: len(str(2**40)) == 13
200+ pretty_table.add_column(14, label="Size", just="r")
201+ pretty_table.add_column(14, label="Expected Size", just="r")
202+ pretty_table.add_column(7, label="% Done", just="r")
203+
204+ print pretty_table.make_header()
205+
206+ for image in images:
207+ print pretty_table.make_row(
208+ image['id'],
209+ image['name'],
210+ image['error'],
211+ image['last_accessed'],
212+ image['size'],
213+ image['expected_size'],
214+ get_percent_done(image))
215+
216+
217+@catch_error('show incomplete cache images')
218+def cache_incomplete(options, args):
219+ """
220+%(prog)s cache-incomplete [options]
221+
222+List images currently being fetched"""
223+ client = get_client(options)
224+ images = client.get_incomplete_cached_images()
225+ if not images:
226+ print "No incomplete cached images."
227+ return SUCCESS
228+
229+ print "Found %d incomplete cached images..." % len(images)
230+
231+ pretty_table = utils.PrettyTable()
232+ pretty_table.add_column(16, label="ID")
233+ pretty_table.add_column(30, label="Name")
234+ pretty_table.add_column(19, label="Last Modified (UTC)")
235+ # 1 TB takes 13 characters to display: len(str(2**40)) == 13
236+ pretty_table.add_column(14, label="Size", just="r")
237+ pretty_table.add_column(14, label="Expected Size", just="r")
238+ pretty_table.add_column(7, label="% Done", just="r")
239+
240+ print pretty_table.make_header()
241+
242+ for image in images:
243+ print pretty_table.make_row(
244+ image['id'],
245+ image['name'],
246+ image['last_modified'],
247+ image['size'],
248+ image['expected_size'],
249+ get_percent_done(image))
250+
251+
252+@catch_error('purge the specified cached image')
253+def cache_purge(options, args):
254+ """
255+%(prog)s cache-purge [options]
256+
257+Purges an image from the cache"""
258+ try:
259+ image_id = args.pop()
260+ except IndexError:
261+ print "Please specify the ID of the image you wish to purge "
262+ print "from the cache as the first argument"
263+ return FAILURE
264+
265+ if not options.force and \
266+ not user_confirm("Purge cached image %s?" % (image_id,), default=False):
267+ print 'Not purging cached image %s' % (image_id,)
268+ return FAILURE
269+
270+ client = get_client(options)
271+ client.purge_cached_image(image_id)
272+
273+ if options.verbose:
274+ print "done"
275+
276+
277+@catch_error('clear all cached images')
278+def cache_clear(options, args):
279+ """
280+%(prog)s cache-clear [options]
281+
282+Removes all images from the cache"""
283+ if not options.force and \
284+ not user_confirm("Clear all cached images?", default=False):
285+ print 'Not purging any cached images.'
286+ return FAILURE
287+
288+ client = get_client(options)
289+ num_purged = client.clear_cached_images()
290+
291+ if options.verbose:
292+ print "Purged %(num_purged)s cached images" % locals()
293+
294+
295+@catch_error('reap invalid images')
296+def cache_reap_invalid(options, args):
297+ """
298+%(prog)s cache-reap-invalid [options]
299+
300+Reaps any invalid images that were left for
301+debugging purposes"""
302+ if not options.force and \
303+ not user_confirm("Reap all invalid cached images?", default=False):
304+ print 'Not reaping any invalid cached images.'
305+ return FAILURE
306+
307+ client = get_client(options)
308+ num_reaped = client.reap_invalid_cached_images()
309+
310+ if options.verbose:
311+ print "Reaped %(num_reaped)s invalid cached images" % locals()
312+
313+
314+@catch_error('reap stalled images')
315+def cache_reap_stalled(options, args):
316+ """
317+%(prog)s cache-reap-stalled [options]
318+
319+Reaps any stalled incomplete images"""
320+ if not options.force and \
321+ not user_confirm("Reap all stalled cached images?", default=False):
322+ print 'Not reaping any stalled cached images.'
323+ return FAILURE
324+
325+ client = get_client(options)
326+ num_reaped = client.reap_stalled_cached_images()
327+
328+ if options.verbose:
329+ print "Reaped %(num_reaped)s stalled cached images" % locals()
330+
331+
332+@catch_error('prefetch the specified cached image')
333+def cache_prefetch(options, args):
334+ """
335+%(prog)s cache-prefetch [options]
336+
337+Pre-fetch an image or list of images into the cache"""
338+ image_ids = args
339+ if not image_ids:
340+ print "Please specify the ID or a list of image IDs of the images "\
341+ "you wish to "
342+ print "prefetch from the cache as the first argument"
343+ return FAILURE
344+
345+ client = get_client(options)
346+ for image_id in image_ids:
347+ if options.verbose:
348+ print "Prefetching image '%s'" % image_id
349+
350+ try:
351+ client.prefetch_cache_image(image_id)
352+ except exception.NotFound:
353+ print "No image with ID %s was found" % image_id
354+ continue
355+
356+ if options.verbose:
357+ print "done"
358+
359+
360+@catch_error('show prefetching images')
361+def cache_prefetching(options, args):
362+ """
363+%(prog)s cache-prefetching [options]
364+
365+List images that are being prefetched"""
366+ client = get_client(options)
367+ images = client.get_prefetching_cache_images()
368+ if not images:
369+ print "No images being prefetched."
370+ return SUCCESS
371+
372+ print "Found %d images being prefetched..." % len(images)
373+
374+ pretty_table = utils.PrettyTable()
375+ pretty_table.add_column(16, label="ID")
376+ pretty_table.add_column(30, label="Name")
377+ pretty_table.add_column(19, label="Last Accessed (UTC)")
378+ pretty_table.add_column(10, label="Status", just="r")
379+
380+ print pretty_table.make_header()
381+
382+ for image in images:
383+ print pretty_table.make_row(
384+ image['id'],
385+ image['name'],
386+ image['last_accessed'],
387+ image['status'])
388+
389+
390 def get_client(options):
391 """
392 Returns a new client object to a Glance server
393 specified by the --host and --port options
394 supplied to the CLI
395 """
396- return client.Client(host=options.host,
397+ return glance_client.Client(host=options.host,
398 port=options.port)
399
400
401@@ -500,28 +771,23 @@
402
403 :param parser: The option parser
404 """
405- COMMANDS = {'help': print_help,
406- 'add': image_add,
407- 'update': image_update,
408- 'delete': image_delete,
409- 'index': images_index,
410- 'details': images_detailed,
411- 'show': image_show,
412- 'clear': images_clear}
413-
414 if not cli_args:
415 cli_args.append('-h') # Show options in usage output...
416
417 (options, args) = parser.parse_args(cli_args)
418
419+ # HACK(sirp): Make the parser available to the print_help method
420+ # print_help is a command, so it only accepts (options, args); we could
421+ # one-off have it take (parser, options, args), however, for now, I think
422+ # this little hack will suffice
423+ options.__parser = parser
424+
425 if not args:
426 parser.print_usage()
427 sys.exit(0)
428- else:
429- command_name = args.pop(0)
430- if command_name not in COMMANDS.keys():
431- sys.exit("Unknown command: %s" % command_name)
432- command = COMMANDS[command_name]
433+
434+ command_name = args.pop(0)
435+ command = lookup_command(parser, command_name)
436
437 return (options, command, args)
438
439@@ -530,24 +796,50 @@
440 """
441 Print help specific to a command
442 """
443- COMMANDS = {'add': image_add,
444- 'update': image_update,
445- 'delete': image_delete,
446- 'index': images_index,
447- 'details': images_detailed,
448- 'show': image_show,
449- 'clear': images_clear}
450-
451 if len(args) != 1:
452 sys.exit("Please specify a command")
453
454- command = args.pop()
455- if command not in COMMANDS.keys():
456+ parser = options.__parser
457+ command_name = args.pop()
458+ command = lookup_command(parser, command_name)
459+
460+ print command.__doc__ % {'prog': os.path.basename(sys.argv[0])}
461+
462+
463+def lookup_command(parser, command_name):
464+ BASE_COMMANDS = {'help': print_help}
465+
466+ IMAGE_COMMANDS = {
467+ 'add': image_add,
468+ 'update': image_update,
469+ 'delete': image_delete,
470+ 'index': images_index,
471+ 'details': images_detailed,
472+ 'show': image_show,
473+ 'clear': images_clear}
474+
475+ CACHE_COMMANDS = {
476+ 'cache-index': cache_index,
477+ 'cache-invalid': cache_invalid,
478+ 'cache-incomplete': cache_incomplete,
479+ 'cache-prefetching': cache_prefetching,
480+ 'cache-prefetch': cache_prefetch,
481+ 'cache-purge': cache_purge,
482+ 'cache-clear': cache_clear,
483+ 'cache-reap-invalid': cache_reap_invalid,
484+ 'cache-reap-stalled': cache_reap_stalled}
485+
486+ commands = {}
487+ for command_set in (BASE_COMMANDS, IMAGE_COMMANDS, CACHE_COMMANDS):
488+ commands.update(command_set)
489+
490+ try:
491+ command = commands[command_name]
492+ except KeyError:
493 parser.print_usage()
494- if args:
495- sys.exit("Unknown command: %s" % command)
496+ sys.exit("Unknown command: %s" % command_name)
497
498- print COMMANDS[command].__doc__ % {'prog': os.path.basename(sys.argv[0])}
499+ return command
500
501
502 def user_confirm(prompt, default=False):
503@@ -596,6 +888,27 @@
504
505 clear Removes all images and metadata from Glance
506
507+
508+Cache Commands:
509+
510+ cache-index List all images currently cached
511+
512+ cache-invalid List current invalid cache images
513+
514+ cache-incomplete List images currently being fetched
515+
516+ cache-prefetching List images that are being prefetched
517+
518+ cache-prefetch Pre-fetch an image or list of images into the cache
519+
520+ cache-purge Purges an image from the cache
521+
522+ cache-clear Removes all images from the cache
523+
524+ cache-reap-invalid Reaps any invalid images that were left for
525+ debugging purposes
526+
527+ cache-reap-stalled Reaps any stalled incomplete images
528 """
529
530 oparser = optparse.OptionParser(version='%%prog %s'
531
532=== added file 'bin/glance-cache-prefetcher'
533--- bin/glance-cache-prefetcher 1970-01-01 00:00:00 +0000
534+++ bin/glance-cache-prefetcher 2011-07-25 22:03:31 +0000
535@@ -0,0 +1,65 @@
536+#!/usr/bin/env python
537+# vim: tabstop=4 shiftwidth=4 softtabstop=4
538+
539+# Copyright 2010 United States Government as represented by the
540+# Administrator of the National Aeronautics and Space Administration.
541+# Copyright 2011 OpenStack LLC.
542+# All Rights Reserved.
543+#
544+# Licensed under the Apache License, Version 2.0 (the "License"); you may
545+# not use this file except in compliance with the License. You may obtain
546+# a copy of the License at
547+#
548+# http://www.apache.org/licenses/LICENSE-2.0
549+#
550+# Unless required by applicable law or agreed to in writing, software
551+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
552+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
553+# License for the specific language governing permissions and limitations
554+# under the License.
555+
556+"""
557+Glance Image Cache Pre-fetcher
558+
559+This is meant to be run as a periodic task from cron.
560+"""
561+
562+import optparse
563+import os
564+import sys
565+
566+# If ../glance/__init__.py exists, add ../ to Python search path, so that
567+# it will override what happens to be installed in /usr/(local/)lib/python...
568+possible_topdir = os.path.normpath(os.path.join(os.path.abspath(sys.argv[0]),
569+ os.pardir,
570+ os.pardir))
571+if os.path.exists(os.path.join(possible_topdir, 'glance', '__init__.py')):
572+ sys.path.insert(0, possible_topdir)
573+
574+from glance import version
575+from glance.common import config
576+from glance.common import wsgi
577+
578+
579+def create_options(parser):
580+ """
581+ Sets up the CLI and config-file options that may be
582+ parsed and program commands.
583+
584+ :param parser: The option parser
585+ """
586+ config.add_common_options(parser)
587+ config.add_log_options(parser)
588+
589+
590+if __name__ == '__main__':
591+ oparser = optparse.OptionParser(version='%%prog %s'
592+ % version.version_string())
593+ create_options(oparser)
594+ (options, args) = config.parse_options(oparser)
595+
596+ try:
597+ conf, app = config.load_paste_app('glance-prefetcher', options, args)
598+ app.run()
599+ except RuntimeError, e:
600+ sys.exit("ERROR: %s" % e)
601
602=== added file 'bin/glance-cache-pruner'
603--- bin/glance-cache-pruner 1970-01-01 00:00:00 +0000
604+++ bin/glance-cache-pruner 2011-07-25 22:03:31 +0000
605@@ -0,0 +1,65 @@
606+#!/usr/bin/env python
607+# vim: tabstop=4 shiftwidth=4 softtabstop=4
608+
609+# Copyright 2010 United States Government as represented by the
610+# Administrator of the National Aeronautics and Space Administration.
611+# Copyright 2011 OpenStack LLC.
612+# All Rights Reserved.
613+#
614+# Licensed under the Apache License, Version 2.0 (the "License"); you may
615+# not use this file except in compliance with the License. You may obtain
616+# a copy of the License at
617+#
618+# http://www.apache.org/licenses/LICENSE-2.0
619+#
620+# Unless required by applicable law or agreed to in writing, software
621+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
622+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
623+# License for the specific language governing permissions and limitations
624+# under the License.
625+
626+"""
627+Glance Image Cache Pruner
628+
629+This is meant to be run as a periodic task, perhaps every half-hour.
630+"""
631+
632+import optparse
633+import os
634+import sys
635+
636+# If ../glance/__init__.py exists, add ../ to Python search path, so that
637+# it will override what happens to be installed in /usr/(local/)lib/python...
638+possible_topdir = os.path.normpath(os.path.join(os.path.abspath(sys.argv[0]),
639+ os.pardir,
640+ os.pardir))
641+if os.path.exists(os.path.join(possible_topdir, 'glance', '__init__.py')):
642+ sys.path.insert(0, possible_topdir)
643+
644+from glance import version
645+from glance.common import config
646+from glance.common import wsgi
647+
648+
649+def create_options(parser):
650+ """
651+ Sets up the CLI and config-file options that may be
652+ parsed and program commands.
653+
654+ :param parser: The option parser
655+ """
656+ config.add_common_options(parser)
657+ config.add_log_options(parser)
658+
659+
660+if __name__ == '__main__':
661+ oparser = optparse.OptionParser(version='%%prog %s'
662+ % version.version_string())
663+ create_options(oparser)
664+ (options, args) = config.parse_options(oparser)
665+
666+ try:
667+ conf, app = config.load_paste_app('glance-pruner', options, args)
668+ app.run()
669+ except RuntimeError, e:
670+ sys.exit("ERROR: %s" % e)
671
672=== added file 'bin/glance-cache-reaper'
673--- bin/glance-cache-reaper 1970-01-01 00:00:00 +0000
674+++ bin/glance-cache-reaper 2011-07-25 22:03:31 +0000
675@@ -0,0 +1,73 @@
676+#!/usr/bin/env python
677+# vim: tabstop=4 shiftwidth=4 softtabstop=4
678+
679+# Copyright 2010 United States Government as represented by the
680+# Administrator of the National Aeronautics and Space Administration.
681+# Copyright 2011 OpenStack LLC.
682+# All Rights Reserved.
683+#
684+# Licensed under the Apache License, Version 2.0 (the "License"); you may
685+# not use this file except in compliance with the License. You may obtain
686+# a copy of the License at
687+#
688+# http://www.apache.org/licenses/LICENSE-2.0
689+#
690+# Unless required by applicable law or agreed to in writing, software
691+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
692+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
693+# License for the specific language governing permissions and limitations
694+# under the License.
695+
696+"""
697+Glance Image Cache Invalid Cache Entry and Stalled Image Reaper
698+
699+This is meant to be run as a periodic task from cron.
700+
701+If something goes wrong while we're caching an image (for example the fetch
702+times out, or an exception is raised), we create an 'invalid' entry. These
703+entires are left around for debugging purposes. However, after some period of
704+time, we want to cleans these up, aka reap them.
705+
706+Also, if an incomplete image hangs around past the image_cache_stall_timeout
707+period, we automatically sweep it up.
708+"""
709+
710+import optparse
711+import os
712+import sys
713+
714+# If ../glance/__init__.py exists, add ../ to Python search path, so that
715+# it will override what happens to be installed in /usr/(local/)lib/python...
716+possible_topdir = os.path.normpath(os.path.join(os.path.abspath(sys.argv[0]),
717+ os.pardir,
718+ os.pardir))
719+if os.path.exists(os.path.join(possible_topdir, 'glance', '__init__.py')):
720+ sys.path.insert(0, possible_topdir)
721+
722+from glance import version
723+from glance.common import config
724+from glance.common import wsgi
725+
726+
727+def create_options(parser):
728+ """
729+ Sets up the CLI and config-file options that may be
730+ parsed and program commands.
731+
732+ :param parser: The option parser
733+ """
734+ config.add_common_options(parser)
735+ config.add_log_options(parser)
736+
737+
738+if __name__ == '__main__':
739+ oparser = optparse.OptionParser(version='%%prog %s'
740+ % version.version_string())
741+ create_options(oparser)
742+ (options, args) = config.parse_options(oparser)
743+
744+ try:
745+ conf, app = config.load_paste_app('glance-reaper', options, args)
746+ app.run()
747+ except RuntimeError, e:
748+ sys.exit("ERROR: %s" % e)
749
750=== modified file 'etc/glance-api.conf'
751--- etc/glance-api.conf 2011-07-23 00:07:38 +0000
752+++ etc/glance-api.conf 2011-07-25 22:03:31 +0000
753@@ -51,6 +51,16 @@
754 # Do we create the container if it does not exist?
755 swift_store_create_container_on_put = False
756
757+# ============ Image Cache Options ========================
758+
759+# Directory that the Image Cache writes data to
760+# Make sure this is also set in glance-pruner.conf
761+image_cache_datadir = /var/lib/glance/image-cache/
762+
763+# Number of seconds after which we should consider an incomplete image to be
764+# stalled and eligible for reaping
765+image_cache_stall_timeout = 86400
766+
767 # ============ Delayed Delete Options =============================
768
769 # Turn on/off delayed delete
770@@ -59,6 +69,9 @@
771 [pipeline:glance-api]
772 pipeline = versionnegotiation context apiv1app
773
774+# To enable Image Cache Management API replace pipeline with below:
775+# pipeline = versionnegotiation imagecache apiv1app
776+
777 [pipeline:versions]
778 pipeline = versionsapp
779
780@@ -71,5 +84,8 @@
781 [filter:versionnegotiation]
782 paste.filter_factory = glance.api.middleware.version_negotiation:filter_factory
783
784+[filter:imagecache]
785+paste.filter_factory = glance.api.middleware.image_cache:filter_factory
786+
787 [filter:context]
788 paste.filter_factory = glance.common.context:filter_factory
789
790=== added file 'etc/glance-prefetcher.conf'
791--- etc/glance-prefetcher.conf 1970-01-01 00:00:00 +0000
792+++ etc/glance-prefetcher.conf 2011-07-25 22:03:31 +0000
793@@ -0,0 +1,21 @@
794+[DEFAULT]
795+# Show more verbose log output (sets INFO log level output)
796+verbose = True
797+
798+# Show debugging output in logs (sets DEBUG log level output)
799+debug = False
800+
801+log_file = /var/log/glance/prefetcher.log
802+
803+# Directory that the Image Cache writes data to
804+# Make sure this is also set in glance-api.conf
805+image_cache_datadir = /var/lib/glance/image-cache/
806+
807+# Address to find the registry server
808+registry_host = 0.0.0.0
809+
810+# Port the registry server is listening on
811+registry_port = 9191
812+
813+[app:glance-prefetcher]
814+paste.app_factory = glance.image_cache.prefetcher:app_factory
815
816=== added file 'etc/glance-pruner.conf'
817--- etc/glance-pruner.conf 1970-01-01 00:00:00 +0000
818+++ etc/glance-pruner.conf 2011-07-25 22:03:31 +0000
819@@ -0,0 +1,28 @@
820+[DEFAULT]
821+# Show more verbose log output (sets INFO log level output)
822+verbose = True
823+
824+# Show debugging output in logs (sets DEBUG log level output)
825+debug = False
826+
827+log_file = /var/log/glance/pruner.log
828+
829+image_cache_max_size_bytes = 1073741824
830+
831+# Percentage of the cache that should be freed (in addition to the overage)
832+# when the cache is pruned
833+#
834+# A percentage of 0% means we prune only as many files as needed to remain
835+# under the cache's max_size. This is space efficient but will lead to
836+# constant pruning as the size bounces just-above and just-below the max_size.
837+#
838+# To mitigate this 'thrashing', you can specify an additional amount of the
839+# cache that should be tossed out on each prune.
840+image_cache_percent_extra_to_free = 0.20
841+
842+# Directory that the Image Cache writes data to
843+# Make sure this is also set in glance-api.conf
844+image_cache_datadir = /var/lib/glance/image-cache/
845+
846+[app:glance-pruner]
847+paste.app_factory = glance.image_cache.pruner:app_factory
848
849=== added file 'etc/glance-reaper.conf'
850--- etc/glance-reaper.conf 1970-01-01 00:00:00 +0000
851+++ etc/glance-reaper.conf 2011-07-25 22:03:31 +0000
852@@ -0,0 +1,29 @@
853+[DEFAULT]
854+# Show more verbose log output (sets INFO log level output)
855+verbose = True
856+
857+# Show debugging output in logs (sets DEBUG log level output)
858+debug = False
859+
860+log_file = /var/log/glance/reaper.log
861+
862+# Directory that the Image Cache writes data to
863+# Make sure this is also set in glance-api.conf
864+image_cache_datadir = /var/lib/glance/image-cache/
865+
866+# image_cache_invalid_entry_grace_period - seconds
867+#
868+# If an exception is raised as we're writing to the cache, the cache-entry is
869+# deemed invalid and moved to <image_cache_datadir>/invalid so that it can be
870+# inspected for debugging purposes.
871+#
872+# This is number of seconds to leave these invalid images around before they
873+# are elibible to be reaped.
874+image_cache_invalid_entry_grace_period = 3600
875+
876+# Number of seconds after which we should consider an incomplete image to be
877+# stalled and eligible for reaping
878+image_cache_stall_timeout = 86400
879+
880+[app:glance-reaper]
881+paste.app_factory = glance.image_cache.reaper:app_factory
882
883=== modified file 'glance/api/__init__.py'
884--- glance/api/__init__.py 2011-05-05 23:12:21 +0000
885+++ glance/api/__init__.py 2011-07-25 22:03:31 +0000
886@@ -14,3 +14,50 @@
887 # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
888 # License for the specific language governing permissions and limitations
889 # under the License.
890+import logging
891+import webob.exc
892+
893+from glance import registry
894+from glance.common import exception
895+
896+
897+logger = logging.getLogger('glance.api')
898+
899+
900+class BaseController(object):
901+ def get_image_meta_or_404(self, request, id):
902+ """
903+ Grabs the image metadata for an image with a supplied
904+ identifier or raises an HTTPNotFound (404) response
905+
906+ :param request: The WSGI/Webob Request object
907+ :param id: The opaque image identifier
908+
909+ :raises HTTPNotFound if image does not exist
910+ """
911+ context = request.context
912+ try:
913+ return registry.get_image_metadata(self.options, context, id)
914+ except exception.NotFound:
915+ msg = "Image with identifier %s not found" % id
916+ logger.debug(msg)
917+ raise webob.exc.HTTPNotFound(
918+ msg, request=request, content_type='text/plain')
919+ except exception.NotAuthorized:
920+ msg = "Unauthorized image access"
921+ logger.debug(msg)
922+ raise webob.exc.HTTPForbidden(msg, request=request,
923+ content_type='text/plain')
924+
925+ def get_active_image_meta_or_404(self, request, id):
926+ """
927+ Same as get_image_meta_or_404 except that it will raise a 404 if the
928+ image isn't 'active'.
929+ """
930+ image = self.get_image_meta_or_404(request, id)
931+ if image['status'] != 'active':
932+ msg = "Image %s is not active" % id
933+ logger.debug(msg)
934+ raise webob.exc.HTTPNotFound(
935+ msg, request=request, content_type='text/plain')
936+ return image
937
938=== added file 'glance/api/cached_images.py'
939--- glance/api/cached_images.py 1970-01-01 00:00:00 +0000
940+++ glance/api/cached_images.py 2011-07-25 22:03:31 +0000
941@@ -0,0 +1,105 @@
942+# vim: tabstop=4 shiftwidth=4 softtabstop=4
943+
944+# Copyright 2011 OpenStack LLC.
945+# All Rights Reserved.
946+#
947+# Licensed under the Apache License, Version 2.0 (the "License"); you may
948+# not use this file except in compliance with the License. You may obtain
949+# a copy of the License at
950+#
951+# http://www.apache.org/licenses/LICENSE-2.0
952+#
953+# Unless required by applicable law or agreed to in writing, software
954+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
955+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
956+# License for the specific language governing permissions and limitations
957+# under the License.
958+
959+"""
960+Controller for Image Cache Management API
961+"""
962+
963+import httplib
964+import json
965+
966+import webob.dec
967+import webob.exc
968+
969+from glance.common import exception
970+from glance.common import wsgi
971+from glance import api
972+from glance import image_cache
973+from glance import registry
974+
975+
976+class Controller(api.BaseController):
977+ """
978+ A controller that produces information on the Glance API versions.
979+ """
980+
981+ def __init__(self, options):
982+ self.options = options
983+ self.cache = image_cache.ImageCache(self.options)
984+
985+ def index(self, req):
986+ status = req.str_params.get('status')
987+ if status == 'invalid':
988+ entries = list(self.cache.invalid_entries())
989+ elif status == 'incomplete':
990+ entries = list(self.cache.incomplete_entries())
991+ elif status == 'prefetching':
992+ entries = list(self.cache.prefetch_entries())
993+ else:
994+ entries = list(self.cache.entries())
995+
996+ return dict(cached_images=entries)
997+
998+ def delete(self, req, id):
999+ self.cache.purge(id)
1000+
1001+ def delete_collection(self, req):
1002+ """
1003+ DELETE /cached_images - Clear all active cached images
1004+ DELETE /cached_images?status=invalid - Reap invalid cached images
1005+ DELETE /cached_images?status=incomplete - Reap stalled cached images
1006+ """
1007+ status = req.str_params.get('status')
1008+ if status == 'invalid':
1009+ num_reaped = self.cache.reap_invalid()
1010+ return dict(num_reaped=num_reaped)
1011+ elif status == 'incomplete':
1012+ num_reaped = self.cache.reap_stalled()
1013+ return dict(num_reaped=num_reaped)
1014+ else:
1015+ num_purged = self.cache.clear()
1016+ return dict(num_purged=num_purged)
1017+
1018+ def update(self, req, id):
1019+ """PUT /cached_images/1 is used to prefetch an image into the cache"""
1020+ image_meta = self.get_active_image_meta_or_404(req, id)
1021+ try:
1022+ self.cache.queue_prefetch(image_meta)
1023+ except exception.Invalid, e:
1024+ raise webob.exc.HTTPBadRequest(explanation=str(e))
1025+
1026+
1027+class CachedImageDeserializer(wsgi.JSONRequestDeserializer):
1028+ pass
1029+
1030+
1031+class CachedImageSerializer(wsgi.JSONResponseSerializer):
1032+ pass
1033+
1034+
1035+def create_resource(options):
1036+ """Cached Images resource factory method"""
1037+ deserializer = CachedImageDeserializer()
1038+ serializer = CachedImageSerializer()
1039+ return wsgi.Resource(Controller(options), deserializer, serializer)
1040+
1041+
1042+def app_factory(global_conf, **local_conf):
1043+ """paste.deploy app factory for creating Cached Images apps"""
1044+ conf = global_conf.copy()
1045+ conf.update(local_conf)
1046+ return Controller(conf)
1047
1048=== added file 'glance/api/middleware/image_cache.py'
1049--- glance/api/middleware/image_cache.py 1970-01-01 00:00:00 +0000
1050+++ glance/api/middleware/image_cache.py 2011-07-25 22:03:31 +0000
1051@@ -0,0 +1,57 @@
1052+# vim: tabstop=4 shiftwidth=4 softtabstop=4
1053+
1054+# Copyright 2011 OpenStack LLC.
1055+# All Rights Reserved.
1056+#
1057+# Licensed under the Apache License, Version 2.0 (the "License"); you may
1058+# not use this file except in compliance with the License. You may obtain
1059+# a copy of the License at
1060+#
1061+# http://www.apache.org/licenses/LICENSE-2.0
1062+#
1063+# Unless required by applicable law or agreed to in writing, software
1064+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
1065+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
1066+# License for the specific language governing permissions and limitations
1067+# under the License.
1068+
1069+"""
1070+Image Cache Management API
1071+"""
1072+
1073+import logging
1074+
1075+from glance.api import cached_images
1076+from glance.common import wsgi
1077+
1078+logger = logging.getLogger('glance.api.middleware.image_cache')
1079+
1080+
1081+class ImageCacheFilter(wsgi.Middleware):
1082+ def __init__(self, app, options):
1083+ super(ImageCacheFilter, self).__init__(app)
1084+
1085+ map = app.map
1086+ resource = cached_images.create_resource(options)
1087+ map.resource("cached_image", "cached_images",
1088+ controller=resource,
1089+ collection={'reap_invalid': 'POST',
1090+ 'reap_stalled': 'POST'})
1091+
1092+ map.connect("/cached_images",
1093+ controller=resource,
1094+ action="delete_collection",
1095+ conditions=dict(method=["DELETE"]))
1096+
1097+
1098+def filter_factory(global_conf, **local_conf):
1099+ """
1100+ Factory method for paste.deploy
1101+ """
1102+ conf = global_conf.copy()
1103+ conf.update(local_conf)
1104+
1105+ def filter(app):
1106+ return ImageCacheFilter(app, conf)
1107+
1108+ return filter
1109
1110=== modified file 'glance/api/v1/__init__.py'
1111--- glance/api/v1/__init__.py 2011-06-27 18:05:29 +0000
1112+++ glance/api/v1/__init__.py 2011-07-25 22:03:31 +0000
1113@@ -34,7 +34,7 @@
1114 mapper = routes.Mapper()
1115 resource = images.create_resource(options)
1116 mapper.resource("image", "images", controller=resource,
1117- collection={'detail': 'GET'})
1118+ collection={'detail': 'GET'})
1119 mapper.connect("/", controller=resource, action="index")
1120 mapper.connect("/images/{id}", controller=resource, action="meta",
1121 conditions=dict(method=["HEAD"]))
1122
1123=== modified file 'glance/api/v1/images.py'
1124--- glance/api/v1/images.py 2011-07-23 03:38:20 +0000
1125+++ glance/api/v1/images.py 2011-07-25 22:03:31 +0000
1126@@ -30,6 +30,8 @@
1127 HTTPBadRequest,
1128 HTTPForbidden)
1129
1130+from glance import api
1131+from glance import image_cache
1132 from glance.common import exception
1133 from glance.common import wsgi
1134 from glance.store import (get_from_backend,
1135@@ -49,8 +51,7 @@
1136 SUPPORTED_PARAMS = ('limit', 'marker', 'sort_key', 'sort_dir')
1137
1138
1139-class Controller(object):
1140-
1141+class Controller(api.BaseController):
1142 """
1143 WSGI controller for images resource in Glance v1 API
1144
1145@@ -186,18 +187,63 @@
1146
1147 :raises HTTPNotFound if image is not available to user
1148 """
1149- image = self.get_image_meta_or_404(req, id)
1150-
1151- def image_iterator():
1152- chunks = get_from_backend(image['location'],
1153- expected_size=image['size'],
1154- options=self.options)
1155-
1156- for chunk in chunks:
1157- yield chunk
1158+ image = self.get_active_image_meta_or_404(req, id)
1159+
1160+ def get_from_store(image):
1161+ """Called if caching disabled"""
1162+ return get_from_backend(image['location'],
1163+ expected_size=image['size'],
1164+ options=self.options)
1165+
1166+ def get_from_cache(image, cache):
1167+ """Called if cache hit"""
1168+ with cache.open(image, "rb") as cache_file:
1169+ chunks = utils.chunkiter(cache_file)
1170+ for chunk in chunks:
1171+ yield chunk
1172+
1173+ def get_from_store_tee_into_cache(image, cache):
1174+ """Called if cache miss"""
1175+ with cache.open(image, "wb") as cache_file:
1176+ chunks = get_from_store(image)
1177+ for chunk in chunks:
1178+ cache_file.write(chunk)
1179+ yield chunk
1180+
1181+ cache = image_cache.ImageCache(self.options)
1182+ if cache.enabled:
1183+ if cache.hit(id):
1184+ # hit
1185+ logger.debug("image cache HIT, retrieving image '%s'"
1186+ " from cache", id)
1187+ image_iterator = get_from_cache(image, cache)
1188+ else:
1189+ # miss
1190+ logger.debug("image cache MISS, retrieving image '%s'"
1191+ " from store and tee'ing into cache", id)
1192+
1193+ # We only want to tee-into the cache if we're not currently
1194+ # prefetching an image
1195+ image_id = image['id']
1196+ if cache.is_image_currently_prefetching(image_id):
1197+ image_iterator = get_from_store(image)
1198+ else:
1199+ # NOTE(sirp): If we're about to download and cache an
1200+ # image which is currently in the prefetch queue, just
1201+ # delete the queue items since we're caching it anyway
1202+ if cache.is_image_queued_for_prefetch(image_id):
1203+ cache.delete_queued_prefetch_image(image_id)
1204+
1205+ image_iterator = get_from_store_tee_into_cache(
1206+ image, cache)
1207+ else:
1208+ # disabled
1209+ logger.debug("image cache DISABLED, retrieving image '%s'"
1210+ " from store", id)
1211+ image_iterator = get_from_store(image)
1212
1213 return {
1214- 'image_iterator': image_iterator(),
1215+ 'image_iterator': image_iterator,
1216 'image_meta': image,
1217 }
1218
1219@@ -275,12 +321,12 @@
1220 store = self.get_store_or_400(req, store_name)
1221
1222 image_id = image_meta['id']
1223- logger.debug("Setting image %s to status 'saving'" % image_id)
1224+ logger.debug("Setting image %s to status 'saving'", image_id)
1225 registry.update_image_metadata(self.options, req.context, image_id,
1226 {'status': 'saving'})
1227 try:
1228 logger.debug("Uploading image data for image %(image_id)s "
1229- "to %(store_name)s store" % locals())
1230+ "to %(store_name)s store", locals())
1231 location, size, checksum = store.add(image_meta['id'],
1232 req.body_file,
1233 self.options)
1234@@ -302,7 +348,7 @@
1235 # from the backend store
1236 logger.debug("Updating image %(image_id)s data. "
1237 "Checksum set to %(checksum)s, size set "
1238- "to %(size)d" % locals())
1239+ "to %(size)d", locals())
1240 registry.update_image_metadata(self.options, req.context,
1241 image_id,
1242 {'checksum': checksum,
1243@@ -506,30 +552,6 @@
1244 req.context, id)
1245 registry.delete_image_metadata(self.options, req.context, id)
1246
1247- def get_image_meta_or_404(self, request, id):
1248- """
1249- Grabs the image metadata for an image with a supplied
1250- identifier or raises an HTTPNotFound (404) response
1251-
1252- :param request: The WSGI/Webob Request object
1253- :param id: The opaque image identifier
1254-
1255- :raises HTTPNotFound if image does not exist
1256- """
1257- try:
1258- return registry.get_image_metadata(self.options,
1259- request.context, id)
1260- except exception.NotFound:
1261- msg = "Image with identifier %s not found" % id
1262- logger.debug(msg)
1263- raise HTTPNotFound(msg, request=request,
1264- content_type='text/plain')
1265- except exception.NotAuthorized:
1266- msg = "Unauthorized image access"
1267- logger.debug(msg)
1268- raise HTTPForbidden(msg, request=request,
1269- content_type='text/plain')
1270-
1271 def get_store_or_400(self, request, store_name):
1272 """
1273 Grabs the storage backend for the supplied store name
1274
1275=== modified file 'glance/client.py'
1276--- glance/client.py 2011-07-20 22:53:44 +0000
1277+++ glance/client.py 2011-07-25 22:03:31 +0000
1278@@ -164,5 +164,120 @@
1279 self.do_request("DELETE", "/images/%s" % image_id)
1280 return True
1281
1282+ def get_cached_images(self, **kwargs):
1283+ """
1284+ Returns a list of images stored in the image cache.
1285+
1286+ :param filters: dictionary of attributes by which the resulting
1287+ collection of images should be filtered
1288+ :param marker: id after which to start the page of images
1289+ :param limit: maximum number of items to return
1290+ :param sort_key: results will be ordered by this image attribute
1291+ :param sort_dir: direction in which to to order results (asc, desc)
1292+ """
1293+ params = self._extract_params(kwargs, v1_images.SUPPORTED_PARAMS)
1294+ res = self.do_request("GET", "/cached_images", params=params)
1295+ data = json.loads(res.read())['cached_images']
1296+ return data
1297+
1298+ def get_invalid_cached_images(self, **kwargs):
1299+ """
1300+ Returns a list of invalid images stored in the image cache.
1301+
1302+ :param filters: dictionary of attributes by which the resulting
1303+ collection of images should be filtered
1304+ :param marker: id after which to start the page of images
1305+ :param limit: maximum number of items to return
1306+ :param sort_key: results will be ordered by this image attribute
1307+ :param sort_dir: direction in which to to order results (asc, desc)
1308+ """
1309+ params = self._extract_params(kwargs, v1_images.SUPPORTED_PARAMS)
1310+ params['status'] = 'invalid'
1311+ res = self.do_request("GET", "/cached_images", params=params)
1312+ data = json.loads(res.read())['cached_images']
1313+ return data
1314+
1315+ def get_incomplete_cached_images(self, **kwargs):
1316+ """
1317+ Returns a list of incomplete images being fetched into cache
1318+
1319+ :param filters: dictionary of attributes by which the resulting
1320+ collection of images should be filtered
1321+ :param marker: id after which to start the page of images
1322+ :param limit: maximum number of items to return
1323+ :param sort_key: results will be ordered by this image attribute
1324+ :param sort_dir: direction in which to to order results (asc, desc)
1325+ """
1326+ params = self._extract_params(kwargs, v1_images.SUPPORTED_PARAMS)
1327+ params['status'] = 'incomplete'
1328+ res = self.do_request("GET", "/cached_images", params=params)
1329+ data = json.loads(res.read())['cached_images']
1330+ return data
1331+
1332+ def purge_cached_image(self, image_id):
1333+ """
1334+ Delete a specified image from the cache
1335+ """
1336+ self.do_request("DELETE", "/cached_images/%s" % image_id)
1337+ return True
1338+
1339+ def clear_cached_images(self):
1340+ """
1341+ Clear all cached images
1342+ """
1343+ res = self.do_request("DELETE", "/cached_images")
1344+ data = json.loads(res.read())
1345+ num_purged = data['num_purged']
1346+ return num_purged
1347+
1348+ def reap_invalid_cached_images(self, **kwargs):
1349+ """
1350+ Reaps any invalid cached images
1351+ """
1352+ params = self._extract_params(kwargs, v1_images.SUPPORTED_PARAMS)
1353+ params['status'] = 'invalid'
1354+ res = self.do_request("DELETE", "/cached_images", params=params)
1355+ data = json.loads(res.read())
1356+ num_reaped = data['num_reaped']
1357+ return num_reaped
1358+
1359+ def reap_stalled_cached_images(self, **kwargs):
1360+ """
1361+ Reaps any stalled cached images
1362+ """
1363+ params = self._extract_params(kwargs, v1_images.SUPPORTED_PARAMS)
1364+ params['status'] = 'incomplete'
1365+ res = self.do_request("DELETE", "/cached_images", params=params)
1366+ data = json.loads(res.read())
1367+ num_reaped = data['num_reaped']
1368+ return num_reaped
1369+
1370+ def prefetch_cache_image(self, image_id):
1371+ """
1372+ Pre-fetch a specified image from the cache
1373+ """
1374+ res = self.do_request("HEAD", "/images/%s" % image_id)
1375+ image = utils.get_image_meta_from_headers(res)
1376+ self.do_request("PUT", "/cached_images/%s" % image_id)
1377+ return True
1378+
1379+ def get_prefetching_cache_images(self, **kwargs):
1380+ """
1381+ Returns a list of images which are actively being prefetched or are
1382+ queued to be prefetched in the future.
1383+
1384+ :param filters: dictionary of attributes by which the resulting
1385+ collection of images should be filtered
1386+ :param marker: id after which to start the page of images
1387+ :param limit: maximum number of items to return
1388+ :param sort_key: results will be ordered by this image attribute
1389+ :param sort_dir: direction in which to to order results (asc, desc)
1390+ """
1391+ params = self._extract_params(kwargs, v1_images.SUPPORTED_PARAMS)
1392+ params['status'] = 'prefetching'
1393+ res = self.do_request("GET", "/cached_images", params=params)
1394+ data = json.loads(res.read())['cached_images']
1395+ return data
1396+
1397
1398 Client = V1Client
1399
1400=== added directory 'glance/image_cache'
1401=== added file 'glance/image_cache/__init__.py'
1402--- glance/image_cache/__init__.py 1970-01-01 00:00:00 +0000
1403+++ glance/image_cache/__init__.py 2011-07-25 22:03:31 +0000
1404@@ -0,0 +1,451 @@
1405+# vim: tabstop=4 shiftwidth=4 softtabstop=4
1406+
1407+# Copyright 2011 OpenStack LLC.
1408+# All Rights Reserved.
1409+#
1410+# Licensed under the Apache License, Version 2.0 (the "License"); you may
1411+# not use this file except in compliance with the License. You may obtain
1412+# a copy of the License at
1413+#
1414+# http://www.apache.org/licenses/LICENSE-2.0
1415+#
1416+# Unless required by applicable law or agreed to in writing, software
1417+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
1418+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
1419+# License for the specific language governing permissions and limitations
1420+# under the License.
1421+
1422+"""
1423+LRU Cache for Image Data
1424+"""
1425+from contextlib import contextmanager
1426+import datetime
1427+import itertools
1428+import logging
1429+import os
1430+import sys
1431+import time
1432+
1433+from glance.common import config
1434+from glance.common import exception
1435+from glance import utils
1436+
1437+logger = logging.getLogger('glance.image_cache')
1438+
1439+
1440+class ImageCache(object):
1441+ """Provides an LRU cache for image data.
1442+
1443+ Data is cached on READ not on WRITE; meaning if the cache is enabled, we
1444+ attempt to read from the cache first, if we don't find the data, we begin
1445+ streaming the data from the 'store' while simultaneously tee'ing the data
1446+ into the cache. Subsequent reads will generate cache HITs for this image.
1447+
1448+ Assumptions
1449+ ===========
1450+
1451+ 1. Cache data directory exists on a filesytem that updates atime on
1452+ reads ('noatime' should NOT be set)
1453+
1454+ 2. Cache data directory exists on a filesystem that supports xattrs.
1455+ This is optional, but highly recommended since it allows us to
1456+ present ops with useful information pertaining to the cache, like
1457+ human readable filenames and statistics.
1458+
1459+ 3. `glance-prune` is scheduled to run as a periodic job via cron. This
1460+ is needed to run the LRU prune strategy to keep the cache size
1461+ within the limits set by the config file.
1462+
1463+
1464+ Cache Directory Notes
1465+ =====================
1466+
1467+ The image cache data directory contains the main cache path, where the
1468+ active cache entries and subdirectories for handling partial downloads
1469+ and errored-out cache images.
1470+
1471+ The layout looks like:
1472+
1473+ image-cache/
1474+ entry1
1475+ entry2
1476+ ...
1477+ incomplete/
1478+ invalid/
1479+ prefetch/
1480+ prefetching/
1481+ """
1482+ def __init__(self, options):
1483+ self.options = options
1484+ self._make_cache_directory_if_needed()
1485+
1486+ def _make_cache_directory_if_needed(self):
1487+ """Creates main cache directory along with incomplete subdirectory"""
1488+ if not self.enabled:
1489+ return
1490+
1491+ # NOTE(sirp): making the incomplete_path will have the effect of
1492+ # creating the main cache path directory as well
1493+ paths = [self.incomplete_path, self.invalid_path, self.prefetch_path,
1494+ self.prefetching_path]
1495+
1496+ for path in paths:
1497+ if os.path.exists(path):
1498+ continue
1499+ logger.info("image cache directory doesn't exist, creating '%s'",
1500+ path)
1501+ os.makedirs(path)
1502+
1503+ @property
1504+ def enabled(self):
1505+ return self.options.get('image_cache_enabled', False)
1506+
1507+ @property
1508+ def path(self):
1509+ """This is the base path for the image cache"""
1510+ datadir = self.options['image_cache_datadir']
1511+ return datadir
1512+
1513+ @property
1514+ def incomplete_path(self):
1515+ """This provides a temporary place to write our cache entries so that
1516+ we we're not storing incomplete objects in the cache directly.
1517+
1518+ When the file is finished writing to, it is moved from the incomplete
1519+ path back out into the main cache directory.
1520+
1521+ The incomplete_path is a subdirectory of the main cache path to ensure
1522+ that they both reside on the same filesystem and thus making moves
1523+ cheap.
1524+ """
1525+ return os.path.join(self.path, 'incomplete')
1526+
1527+ @property
1528+ def invalid_path(self):
1529+ """Place to move corrupted images
1530+
1531+ If an exception is raised while we're writing an image to the
1532+ incomplete_path, we move the incomplete image to here.
1533+ """
1534+ return os.path.join(self.path, 'invalid')
1535+
1536+ @property
1537+ def prefetch_path(self):
1538+ """This contains a list of image ids that should be pre-fetched into
1539+ the cache
1540+ """
1541+ return os.path.join(self.path, 'prefetch')
1542+
1543+ @property
1544+ def prefetching_path(self):
1545+ """This contains image ids that currently being prefetched"""
1546+ return os.path.join(self.path, 'prefetching')
1547+
1548+ def path_for_image(self, image_id):
1549+ """This crafts an absolute path to a specific entry"""
1550+ return os.path.join(self.path, str(image_id))
1551+
1552+ def incomplete_path_for_image(self, image_id):
1553+ """This crafts an absolute path to a specific entry in the incomplete
1554+ directory
1555+ """
1556+ return os.path.join(self.incomplete_path, str(image_id))
1557+
1558+ def invalid_path_for_image(self, image_id):
1559+ """This crafts an absolute path to a specific entry in the invalid
1560+ directory
1561+ """
1562+ return os.path.join(self.invalid_path, str(image_id))
1563+
1564+ @contextmanager
1565+ def open(self, image_meta, mode="rb"):
1566+ """Open a cache image for reading or writing.
1567+
1568+ We have two possible scenarios:
1569+
1570+ 1. READ: we should attempt to read the file from the cache's
1571+ main directory
1572+
1573+ 2. WRITE: we should write to a file under the cache's incomplete
1574+ directory, and when it's finished, move it out the main cache
1575+ directory.
1576+ """
1577+ if mode == 'wb':
1578+ with self._open_write(image_meta, mode) as cache_file:
1579+ yield cache_file
1580+ elif mode == 'rb':
1581+ with self._open_read(image_meta, mode) as cache_file:
1582+ yield cache_file
1583+ else:
1584+ # NOTE(sirp): `rw` and `a' modes are not supported since image
1585+ # data is immutable, we `wb` it once, then `rb` multiple times.
1586+ raise Exception("mode '%s' not supported" % mode)
1587+
1588+ @contextmanager
1589+ def _open_write(self, image_meta, mode):
1590+ image_id = image_meta['id']
1591+ incomplete_path = self.incomplete_path_for_image(image_id)
1592+
1593+ def set_xattr(key, value):
1594+ utils.set_xattr(incomplete_path, key, value)
1595+
1596+ def commit():
1597+ set_xattr('image_name', image_meta['name'])
1598+ set_xattr('hits', 0)
1599+
1600+ final_path = self.path_for_image(image_id)
1601+ logger.debug("fetch finished, commiting by moving "
1602+ "'%(incomplete_path)s' to '%(final_path)s'",
1603+ dict(incomplete_path=incomplete_path,
1604+ final_path=final_path))
1605+ os.rename(incomplete_path, final_path)
1606+
1607+ def rollback(e):
1608+ set_xattr('image_name', image_meta['name'])
1609+ set_xattr('error', str(e))
1610+
1611+ invalid_path = self.invalid_path_for_image(image_id)
1612+ logger.debug("fetch errored, rolling back by moving "
1613+ "'%(incomplete_path)s' to '%(invalid_path)s'",
1614+ dict(incomplete_path=incomplete_path,
1615+ invalid_path=invalid_path))
1616+ os.rename(incomplete_path, invalid_path)
1617+
1618+ try:
1619+ with open(incomplete_path, mode) as cache_file:
1620+ set_xattr('expected_size', image_meta['size'])
1621+ yield cache_file
1622+ except Exception as e:
1623+ rollback(e)
1624+ raise
1625+ else:
1626+ commit()
1627+
1628+ @contextmanager
1629+ def _open_read(self, image_meta, mode):
1630+ image_id = image_meta['id']
1631+ path = self.path_for_image(image_id)
1632+ with open(path, mode) as cache_file:
1633+ yield cache_file
1634+
1635+ utils.inc_xattr(path, 'hits') # bump the hit count
1636+
1637+ def hit(self, image_id):
1638+ return os.path.exists(self.path_for_image(image_id))
1639+
1640+ @staticmethod
1641+ def _delete_file(path):
1642+ if os.path.exists(path):
1643+ logger.debug("deleting image cache file '%s'", path)
1644+ os.unlink(path)
1645+ else:
1646+ logger.warn("image cache file '%s' doesn't exist, unable to"
1647+ " delete", path)
1648+
1649+ def purge(self, image_id):
1650+ path = self.path_for_image(image_id)
1651+ self._delete_file(path)
1652+
1653+ def clear(self):
1654+ purged = 0
1655+ for path in self.get_all_regular_files(self.path):
1656+ self._delete_file(path)
1657+ purged += 1
1658+ return purged
1659+
1660+ def is_image_currently_being_written(self, image_id):
1661+ """Returns true if we're currently downloading an image"""
1662+ incomplete_path = self.incomplete_path_for_image(image_id)
1663+ return os.path.exists(incomplete_path)
1664+
1665+ def is_currently_prefetching_any_images(self):
1666+ """True if we are currently prefetching an image.
1667+
1668+ We only allow one prefetch to occur at a time.
1669+ """
1670+ return len(os.listdir(self.prefetching_path)) > 0
1671+
1672+ def is_image_queued_for_prefetch(self, image_id):
1673+ prefetch_path = os.path.join(self.prefetch_path, str(image_id))
1674+ return os.path.exists(prefetch_path)
1675+
1676+ def is_image_currently_prefetching(self, image_id):
1677+ prefetching_path = os.path.join(self.prefetching_path, str(image_id))
1678+ return os.path.exists(prefetching_path)
1679+
1680+ def queue_prefetch(self, image_meta):
1681+ """This adds a image to be prefetched to the queue directory.
1682+
1683+ If the image already exists in the queue directory or the
1684+ prefetching directory, we ignore it.
1685+ """
1686+ image_id = image_meta['id']
1687+
1688+ if self.hit(image_id):
1689+ msg = "Skipping prefetch, image '%s' already cached" % image_id
1690+ logger.warn(msg)
1691+ raise exception.Invalid(msg)
1692+
1693+ if self.is_image_currently_prefetching(image_id):
1694+ msg = "Skipping prefetch, already prefetching image '%s'"\
1695+ % image_id
1696+ logger.warn(msg)
1697+ raise exception.Invalid(msg)
1698+
1699+ if self.is_image_queued_for_prefetch(image_id):
1700+ msg = "Skipping prefetch, image '%s' already queued for"\
1701+ " prefetching" % image_id
1702+ logger.warn(msg)
1703+ raise exception.Invalid(msg)
1704+
1705+ prefetch_path = os.path.join(self.prefetch_path, str(image_id))
1706+
1707+ # Touch the file to add it to the queue
1708+ with open(prefetch_path, "w") as f:
1709+ pass
1710+
1711+ utils.set_xattr(prefetch_path, 'image_name', image_meta['name'])
1712+
1713+ def delete_queued_prefetch_image(self, image_id):
1714+ prefetch_path = os.path.join(self.prefetch_path, str(image_id))
1715+ self._delete_file(prefetch_path)
1716+
1717+ def delete_prefetching_image(self, image_id):
1718+ prefetching_path = os.path.join(self.prefetching_path, str(image_id))
1719+ self._delete_file(prefetching_path)
1720+
1721+ def pop_prefetch_item(self):
1722+ """This returns the next prefetch job.
1723+
1724+ The prefetch directory is treated like a FIFO; so we sort by modified
1725+ time and pick the oldest.
1726+ """
1727+ items = []
1728+ for path in self.get_all_regular_files(self.prefetch_path):
1729+ mtime = os.path.getmtime(path)
1730+ items.append((mtime, path))
1731+
1732+ if not items:
1733+ raise IndexError
1734+
1735+ # Sort oldest files to the end of the list
1736+ items.sort(reverse=True)
1737+
1738+ mtime, path = items.pop()
1739+ image_id = os.path.basename(path)
1740+ return image_id
1741+
1742+ def do_prefetch(self, image_id):
1743+ """This moves the file from the prefetch queue path to the in-progress
1744+ prefetching path (so we don't try to prefetch something twice).
1745+ """
1746+ prefetch_path = os.path.join(self.prefetch_path, str(image_id))
1747+ prefetching_path = os.path.join(self.prefetching_path, str(image_id))
1748+ os.rename(prefetch_path, prefetching_path)
1749+
1750+ @staticmethod
1751+ def get_all_regular_files(basepath):
1752+ for fname in os.listdir(basepath):
1753+ path = os.path.join(basepath, fname)
1754+ if os.path.isfile(path):
1755+ yield path
1756+
1757+ def _base_entries(self, basepath):
1758+ def iso8601_from_timestamp(timestamp):
1759+ return datetime.datetime.utcfromtimestamp(timestamp)\
1760+ .isoformat()
1761+
1762+ for path in self.get_all_regular_files(basepath):
1763+ filename = os.path.basename(path)
1764+ try:
1765+ image_id = int(filename)
1766+ except ValueError, TypeError:
1767+ continue
1768+
1769+ entry = {}
1770+ entry['id'] = image_id
1771+ entry['path'] = path
1772+ entry['name'] = utils.get_xattr(path, 'image_name',
1773+ default='UNKNOWN')
1774+
1775+ mtime = os.path.getmtime(path)
1776+ entry['last_modified'] = iso8601_from_timestamp(mtime)
1777+
1778+ atime = os.path.getatime(path)
1779+ entry['last_accessed'] = iso8601_from_timestamp(atime)
1780+
1781+ entry['size'] = os.path.getsize(path)
1782+
1783+ entry['expected_size'] = utils.get_xattr(
1784+ path, 'expected_size', default='UNKNOWN')
1785+
1786+ yield entry
1787+
1788+ def invalid_entries(self):
1789+ """Cache info for invalid cached images"""
1790+ for entry in self._base_entries(self.invalid_path):
1791+ path = entry['path']
1792+ entry['error'] = utils.get_xattr(path, 'error', default='UNKNOWN')
1793+ yield entry
1794+
1795+ def incomplete_entries(self):
1796+ """Cache info for invalid cached images"""
1797+ for entry in self._base_entries(self.incomplete_path):
1798+ yield entry
1799+
1800+ def prefetch_entries(self):
1801+ """Cache info for both queued and in-progress prefetch jobs"""
1802+ both_entries = itertools.chain(
1803+ self._base_entries(self.prefetch_path),
1804+ self._base_entries(self.prefetching_path))
1805+
1806+ for entry in both_entries:
1807+ path = entry['path']
1808+ entry['status'] = 'in-progress' if 'prefetching' in path\
1809+ else 'queued'
1810+ yield entry
1811+
1812+ def entries(self):
1813+ """Cache info for currently cached images"""
1814+ for entry in self._base_entries(self.path):
1815+ path = entry['path']
1816+ entry['hits'] = utils.get_xattr(path, 'hits', default='UNKNOWN')
1817+ yield entry
1818+
1819+ def _reap_old_files(self, dirpath, entry_type, grace=None):
1820+ """
1821+ """
1822+ now = time.time()
1823+ reaped = 0
1824+ for path in self.get_all_regular_files(dirpath):
1825+ mtime = os.path.getmtime(path)
1826+ age = now - mtime
1827+ if not grace:
1828+ logger.debug("No grace period, reaping '%(path)s'"
1829+ " immediately", locals())
1830+ self._delete_file(path)
1831+ reaped += 1
1832+ elif age > grace:
1833+ logger.debug("Cache entry '%(path)s' exceeds grace period, "
1834+ "(%(age)i s > %(grace)i s)", locals())
1835+ self._delete_file(path)
1836+ reaped += 1
1837+
1838+ logger.info("Reaped %(reaped)s %(entry_type)s cache entries",
1839+ locals())
1840+ return reaped
1841+
1842+ def reap_invalid(self, grace=None):
1843+ """Remove any invalid cache entries
1844+
1845+ :param grace: Number of seconds to keep an invalid entry around for
1846+ debugging purposes. If None, then delete immediately.
1847+ """
1848+ return self._reap_old_files(self.invalid_path, 'invalid', grace=grace)
1849+
1850+ def reap_stalled(self):
1851+ """Remove any stalled cache entries"""
1852+ stall_timeout = int(self.options.get('image_cache_stall_timeout',
1853+ 86400))
1854+ return self._reap_old_files(self.incomplete_path, 'stalled',
1855+ grace=stall_timeout)
1856
1857=== added file 'glance/image_cache/prefetcher.py'
1858--- glance/image_cache/prefetcher.py 1970-01-01 00:00:00 +0000
1859+++ glance/image_cache/prefetcher.py 2011-07-25 22:03:31 +0000
1860@@ -0,0 +1,90 @@
1861+# vim: tabstop=4 shiftwidth=4 softtabstop=4
1862+
1863+# Copyright 2011 OpenStack LLC.
1864+# All Rights Reserved.
1865+#
1866+# Licensed under the Apache License, Version 2.0 (the "License"); you may
1867+# not use this file except in compliance with the License. You may obtain
1868+# a copy of the License at
1869+#
1870+# http://www.apache.org/licenses/LICENSE-2.0
1871+#
1872+# Unless required by applicable law or agreed to in writing, software
1873+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
1874+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
1875+# License for the specific language governing permissions and limitations
1876+# under the License.
1877+
1878+"""
1879+Prefetches images into the Image Cache
1880+"""
1881+import logging
1882+import os
1883+import stat
1884+import time
1885+
1886+from glance.common import config
1887+from glance.common import context
1888+from glance.image_cache import ImageCache
1889+from glance import registry
1890+from glance.store import get_from_backend
1891+
1892+
1893+logger = logging.getLogger('glance.image_cache.prefetcher')
1894+
1895+
1896+class Prefetcher(object):
1897+ def __init__(self, options):
1898+ self.options = options
1899+ self.cache = ImageCache(options)
1900+
1901+ def fetch_image_into_cache(self, image_id):
1902+ ctx = context.RequestContext(is_admin=True, show_deleted=True)
1903+ image_meta = registry.get_image_metadata(
1904+ self.options, ctx, image_id)
1905+ with self.cache.open(image_meta, "wb") as cache_file:
1906+ chunks = get_from_backend(image_meta['location'],
1907+ expected_size=image_meta['size'],
1908+ options=self.options)
1909+ for chunk in chunks:
1910+ cache_file.write(chunk)
1911+
1912+ def run(self):
1913+ if self.cache.is_currently_prefetching_any_images():
1914+ logger.debug("Currently prefetching, going back to sleep...")
1915+ return
1916+
1917+ try:
1918+ image_id = self.cache.pop_prefetch_item()
1919+ except IndexError:
1920+ logger.debug("Nothing to prefetch, going back to sleep...")
1921+ return
1922+
1923+ if self.cache.hit(image_id):
1924+ logger.warn("Image %s is already in the cache, deleting "
1925+ "prefetch job and going back to sleep...", image_id)
1926+ self.cache.delete_queued_prefetch_image(image_id)
1927+ return
1928+
1929+ # NOTE(sirp): if someone is already downloading an image that is in
1930+ # the prefetch queue, then go ahead and delete that item and try to
1931+ # prefetch another
1932+ if self.cache.is_image_currently_being_written(image_id):
1933+ logger.warn("Image %s is already being cached, deleting "
1934+ "prefetch job and going back to sleep...", image_id)
1935+ self.cache.delete_queued_prefetch_image(image_id)
1936+ return
1937+
1938+ logger.debug("Prefetching '%s'", image_id)
1939+ self.cache.do_prefetch(image_id)
1940+
1941+ try:
1942+ self.fetch_image_into_cache(image_id)
1943+ finally:
1944+ self.cache.delete_prefetching_image(image_id)
1945+
1946+
1947+def app_factory(global_config, **local_conf):
1948+ conf = global_config.copy()
1949+ conf.update(local_conf)
1950+ return Prefetcher(conf)
1951
1952=== added file 'glance/image_cache/pruner.py'
1953--- glance/image_cache/pruner.py 1970-01-01 00:00:00 +0000
1954+++ glance/image_cache/pruner.py 2011-07-25 22:03:31 +0000
1955@@ -0,0 +1,115 @@
1956+# vim: tabstop=4 shiftwidth=4 softtabstop=4
1957+
1958+# Copyright 2011 OpenStack LLC.
1959+# All Rights Reserved.
1960+#
1961+# Licensed under the Apache License, Version 2.0 (the "License"); you may
1962+# not use this file except in compliance with the License. You may obtain
1963+# a copy of the License at
1964+#
1965+# http://www.apache.org/licenses/LICENSE-2.0
1966+#
1967+# Unless required by applicable law or agreed to in writing, software
1968+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
1969+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
1970+# License for the specific language governing permissions and limitations
1971+# under the License.
1972+
1973+"""
1974+Prunes the Image Cache
1975+"""
1976+import logging
1977+import os
1978+import stat
1979+import time
1980+
1981+from glance.common import config
1982+from glance.image_cache import ImageCache
1983+
1984+logger = logging.getLogger('glance.image_cache.pruner')
1985+
1986+
1987+class Pruner(object):
1988+ def __init__(self, options):
1989+ self.options = options
1990+ self.cache = ImageCache(options)
1991+
1992+ @property
1993+ def max_size(self):
1994+ default = 1 * 1024 * 1024 * 1024 # 1 GB
1995+ return config.get_option(
1996+ self.options, 'image_cache_max_size_bytes',
1997+ type='int', default=default)
1998+
1999+ @property
2000+ def percent_extra_to_free(self):
2001+ return config.get_option(
2002+ self.options, 'image_cache_percent_extra_to_free',
2003+ type='float', default=0.05)
2004+
2005+ def run(self):
2006+ self.prune_cache()
2007+
2008+ def prune_cache(self):
2009+ """Prune the cache using an LRU strategy"""
2010+
2011+ # NOTE(sirp): 'Recency' is determined via the filesystem, first using
2012+ # atime (access time) and falling back to mtime (modified time).
2013+ #
2014+ # It has become more common to disable access-time updates by setting
2015+ # the `noatime` option for the filesystem. `noatime` is NOT compatible
2016+ # with this method.
2017+ #
2018+ # If `noatime` needs to be supported, we will need to persist access
2019+ # times elsewhere (either as a separate file, in the DB, or as
2020+ # an xattr).
2021+ def get_stats():
2022+ stats = []
2023+ for path in self.cache.get_all_regular_files(self.cache.path):
2024+ file_info = os.stat(path)
2025+ stats.append((file_info[stat.ST_ATIME], # access time
2026+ file_info[stat.ST_MTIME], # modification time
2027+ file_info[stat.ST_SIZE], # size in bytes
2028+ path)) # absolute path
2029+ return stats
2030+
2031+ def prune_lru(stats, to_free):
2032+ # Sort older access and modified times to the back
2033+ stats.sort(reverse=True)
2034+
2035+ freed = 0
2036+ while to_free > 0:
2037+ atime, mtime, size, path = stats.pop()
2038+ logger.debug("deleting '%(path)s' to free %(size)d B",
2039+ locals())
2040+ os.unlink(path)
2041+ to_free -= size
2042+ freed += size
2043+
2044+ return freed
2045+
2046+ stats = get_stats()
2047+
2048+ # Check for overage
2049+ cur_size = sum(s[2] for s in stats)
2050+ max_size = self.max_size
2051+ logger.debug("cur_size=%(cur_size)d B max_size=%(max_size)d B",
2052+ locals())
2053+ if cur_size <= max_size:
2054+ logger.debug("cache has free space, skipping prune...")
2055+ return
2056+
2057+ overage = cur_size - max_size
2058+ extra = max_size * self.percent_extra_to_free
2059+ to_free = overage + extra
2060+ logger.debug("overage=%(overage)d B extra=%(extra)d B"
2061+ " total=%(to_free)d B", locals())
2062+
2063+ freed = prune_lru(stats, to_free)
2064+ logger.debug("finished pruning, freed %(freed)d bytes", locals())
2065+
2066+
2067+def app_factory(global_config, **local_conf):
2068+ conf = global_config.copy()
2069+ conf.update(local_conf)
2070+ return Pruner(conf)
2071
2072=== added file 'glance/image_cache/reaper.py'
2073--- glance/image_cache/reaper.py 1970-01-01 00:00:00 +0000
2074+++ glance/image_cache/reaper.py 2011-07-25 22:03:31 +0000
2075@@ -0,0 +1,45 @@
2076+# vim: tabstop=4 shiftwidth=4 softtabstop=4
2077+
2078+# Copyright 2011 OpenStack LLC.
2079+# All Rights Reserved.
2080+#
2081+# Licensed under the Apache License, Version 2.0 (the "License"); you may
2082+# not use this file except in compliance with the License. You may obtain
2083+# a copy of the License at
2084+#
2085+# http://www.apache.org/licenses/LICENSE-2.0
2086+#
2087+# Unless required by applicable law or agreed to in writing, software
2088+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
2089+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
2090+# License for the specific language governing permissions and limitations
2091+# under the License.
2092+
2093+"""
2094+Reaps any invalid cache entries that exceed the grace period
2095+"""
2096+import logging
2097+
2098+from glance.image_cache import ImageCache
2099+
2100+
2101+logger = logging.getLogger('glance.image_cache.reaper')
2102+
2103+
2104+class Reaper(object):
2105+ def __init__(self, options):
2106+ self.options = options
2107+ self.cache = ImageCache(options)
2108+
2109+ def run(self):
2110+ invalid_grace = int(self.options.get(
2111+ 'image_cache_invalid_entry_grace_period',
2112+ 3600))
2113+ self.cache.reap_invalid(grace=invalid_grace)
2114+ self.cache.reap_stalled()
2115+
2116+
2117+def app_factory(global_config, **local_conf):
2118+ conf = global_config.copy()
2119+ conf.update(local_conf)
2120+ return Reaper(conf)
2121
2122=== modified file 'glance/utils.py'
2123--- glance/utils.py 2011-06-11 18:18:03 +0000
2124+++ glance/utils.py 2011-07-25 22:03:31 +0000
2125@@ -18,6 +18,12 @@
2126 """
2127 A few utility routines used throughout Glance
2128 """
2129+import errno
2130+import logging
2131+
2132+import xattr
2133+
2134+logger = logging.getLogger('glance.utils')
2135
2136
2137 def image_meta_to_http_headers(image_meta):
2138@@ -99,3 +105,158 @@
2139 :param req: Webob.Request object
2140 """
2141 return req.content_length or 'transfer-encoding' in req.headers
2142+
2143+
2144+def chunkiter(fp, chunk_size=65536):
2145+ """Return an iterator to a file-like obj which yields fixed size chunks
2146+
2147+ :param fp: a file-like object
2148+ :param chunk_size: maximum size of chunk
2149+ """
2150+ while True:
2151+ chunk = fp.read(chunk_size)
2152+ if chunk:
2153+ yield chunk
2154+ else:
2155+ break
2156+
2157+
2158+class PrettyTable(object):
2159+ """Creates an ASCII art table for use in bin/glance
2160+
2161+ Example:
2162+
2163+ ID Name Size Hits
2164+ --- ----------------- ------------ -----
2165+ 122 image 22 0
2166+ """
2167+ def __init__(self):
2168+ self.columns = []
2169+
2170+ def add_column(self, width, label="", just='l'):
2171+ """Add a column to the table
2172+
2173+ :param width: number of characters wide the column should be
2174+ :param label: column heading
2175+ :param just: justification for the column, 'l' for left,
2176+ 'r' for right
2177+ """
2178+ self.columns.append((width, label, just))
2179+
2180+ def make_header(self):
2181+ label_parts = []
2182+ break_parts = []
2183+ for width, label, _ in self.columns:
2184+ # NOTE(sirp): headers are always left justified
2185+ label_part = self._clip_and_justify(label, width, 'l')
2186+ label_parts.append(label_part)
2187+
2188+ break_part = '-' * width
2189+ break_parts.append(break_part)
2190+
2191+ label_line = ' '.join(label_parts)
2192+ break_line = ' '.join(break_parts)
2193+ return '\n'.join([label_line, break_line])
2194+
2195+ def make_row(self, *args):
2196+ row = args
2197+ row_parts = []
2198+ for data, (width, _, just) in zip(row, self.columns):
2199+ row_part = self._clip_and_justify(data, width, just)
2200+ row_parts.append(row_part)
2201+
2202+ row_line = ' '.join(row_parts)
2203+ return row_line
2204+
2205+ @staticmethod
2206+ def _clip_and_justify(data, width, just):
2207+ # clip field to column width
2208+ clipped_data = str(data)[:width]
2209+
2210+ if just == 'r':
2211+ # right justify
2212+ justified = clipped_data.rjust(width)
2213+ else:
2214+ # left justify
2215+ justified = clipped_data.ljust(width)
2216+
2217+ return justified
2218+
2219+
2220+def _make_namespaced_xattr_key(key, namespace='user'):
2221+ """Create a fully-qualified xattr-key by including the intended namespace.
2222+
2223+ Namespacing differs among OSes[1]:
2224+
2225+ FreeBSD: user, system
2226+ Linux: user, system, trusted, security
2227+ MacOS X: not needed
2228+
2229+ Mac OS X won't break if we include a namespace qualifier, so, for
2230+ simplicity, we always include it.
2231+
2232+ --
2233+ [1] http://en.wikipedia.org/wiki/Extended_file_attributes
2234+ """
2235+ namespaced_key = ".".join([namespace, key])
2236+ return namespaced_key
2237+
2238+
2239+def get_xattr(path, key, **kwargs):
2240+ """Return the value for a particular xattr
2241+
2242+ If the key doesn't not exist, or xattrs aren't supported by the file
2243+ system then a KeyError will be raised, that is, unless you specify a
2244+ default using kwargs.
2245+ """
2246+ namespaced_key = _make_namespaced_xattr_key(key)
2247+ entry_xattr = xattr.xattr(path)
2248+ try:
2249+ return entry_xattr[namespaced_key]
2250+ except KeyError:
2251+ if 'default' in kwargs:
2252+ return kwargs['default']
2253+ else:
2254+ raise
2255+
2256+
2257+def set_xattr(path, key, value):
2258+ """Set the value of a specified xattr.
2259+
2260+ If xattrs aren't supported by the file-system, we skip setting the value.
2261+ """
2262+ namespaced_key = _make_namespaced_xattr_key(key)
2263+ entry_xattr = xattr.xattr(path)
2264+ try:
2265+ entry_xattr.set(namespaced_key, str(value))
2266+ except IOError as e:
2267+ if e.errno == errno.EOPNOTSUPP:
2268+ logger.warn("xattrs not supported, skipping...")
2269+ else:
2270+ raise
2271+
2272+
2273+def inc_xattr(path, key, n=1):
2274+ """Increment the value of an xattr (assuming it is an integer).
2275+
2276+ BEWARE, this code *does* have a RACE CONDITION, since the
2277+ read/update/write sequence is not atomic.
2278+
2279+ Since the use-case for this function is collecting stats--not critical--
2280+ the benefits of simple, lock-free code out-weighs the possibility of an
2281+ occasional hit not being counted.
2282+ """
2283+ try:
2284+ count = int(get_xattr(path, key))
2285+ except KeyError:
2286+ # NOTE(sirp): a KeyError is generated in two cases:
2287+ # 1) xattrs is not supported by the filesystem
2288+ # 2) the key is not present on the file
2289+ #
2290+ # In either case, just ignore it...
2291+ pass
2292+ else:
2293+ # NOTE(sirp): only try to bump the count if xattrs is supported
2294+ # and the key is present
2295+ count += n
2296+ set_xattr(path, key, str(count))
2297
2298=== modified file 'setup.py'
2299--- setup.py 2011-04-15 09:34:17 +0000
2300+++ setup.py 2011-07-25 22:03:31 +0000
2301@@ -87,6 +87,9 @@
2302 ],
2303 scripts=['bin/glance',
2304 'bin/glance-api',
2305+ 'bin/glance-cache-prefetcher',
2306+ 'bin/glance-cache-pruner',
2307+ 'bin/glance-cache-reaper',
2308 'bin/glance-control',
2309 'bin/glance-manage',
2310 'bin/glance-registry',
2311
2312=== modified file 'tools/pip-requires'
2313--- tools/pip-requires 2011-06-22 14:19:33 +0000
2314+++ tools/pip-requires 2011-07-25 22:03:31 +0000
2315@@ -18,3 +18,4 @@
2316 bzr
2317 httplib2
2318 hashlib
2319+xattr

Subscribers

People subscribed via source and target branches