Merge lp:~rconradharris/glance/cached-images-middleware into lp:~hudson-openstack/glance/trunk
- cached-images-middleware
- Merge into trunk
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 |
Related bugs: |
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 |
Commit message
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
Ed Leafe (ed-leafe) wrote : | # |
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 ...
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
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.
1571 + logger.
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
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. :)
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:/
-jay
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.
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:/
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.
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_
DELETE /cached_
3) Can the responsibilities assumed by cache-reaper and cache-pruner be consolidated into a single file?
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_
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).
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.
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?
Preview Diff
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 |
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.