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