Merge lp:~jaypipes/nova/glance-image-service into lp:~hudson-openstack/nova/trunk

Proposed by Jay Pipes
Status: Merged
Approved by: Jay Pipes
Approved revision: 325
Merged at revision: 330
Proposed branch: lp:~jaypipes/nova/glance-image-service
Merge into: lp:~hudson-openstack/nova/trunk
Prerequisite: lp:~jaypipes/nova/cleanup-for-nosetests
Diff against target: 594 lines (+420/-41)
7 files modified
nova/api/rackspace/images.py (+6/-1)
nova/api/rackspace/servers.py (+1/-1)
nova/flags.py (+4/-0)
nova/image/service.py (+215/-22)
nova/tests/api/rackspace/fakes.py (+74/-0)
nova/tests/api/rackspace/test_images.py (+119/-17)
nova/tests/api/rackspace/test_servers.py (+1/-0)
To merge this branch: bzr merge lp:~jaypipes/nova/glance-image-service
Reviewer Review Type Date Requested Status
Michael Gundlach (community) Approve
Rick Harris (community) Approve
Christopher MacGown (community) Approve
Review via email: mp+37523@code.launchpad.net

Commit message

Adds stubs and tests for GlanceImageService and LocalImageService.
Adds basic plumbing for ParallaxClient and TellerClient and hooks that into the GlanceImageService.

Fixes lp654843

Description of the change

Adds stubs and tests for GlanceImageService and LocalImageService.
Adds basic plumbing for ParallaxClient and TellerClient and hooks that into the GlanceImageService.

Fixes lp654843

To post a comment you must log in.
Revision history for this message
Christopher MacGown (0x44) wrote :

Looks good to me.

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

Overall, looks excellent.

I think the Parallax get_image and get_image_metadata might need a slight modification. Currently, Parallax returns the JSON with the form dict(image=image_info) or dict(images=image_list). So, you probably want to

try:
    return json.loads(resp.read())['images']
except KeyError:
     raise ImageServiceException('Received malformed json from Parallax')

(The reason I return the json as dict(image=image_info) rather than just returning the image_info as a dict directly-- as would make sense-- is that, when we do support XML, we'll need a name for the root tag. By doing it this way, JSON and XML can be derived from the exact same dict-representation.)

A teensy correction:

186 + # TODO(jaypipes): return or raise HTTP error?
187 + return []

get_image_metadata should return None if image is not found/available.

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

Got it, thanks Rick. Will fix up shortly.

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

OK, changes made and a new unit test added...

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

Looks good, thanks :)

review: Approve
323. By Jay Pipes

Merge trunk

324. By Jay Pipes

Merge cerberus and trunk

325. By Jay Pipes

Merge overwrote import_object() load of image service.

Revision history for this message
Michael Gundlach (gundlach) wrote :

lgtm

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'nova/api/rackspace/images.py'
2--- nova/api/rackspace/images.py 2010-09-29 16:27:56 +0000
3+++ nova/api/rackspace/images.py 2010-10-05 20:21:00 +0000
4@@ -17,12 +17,17 @@
5
6 from webob import exc
7
8+from nova import flags
9+from nova import utils
10 from nova import wsgi
11 from nova.api.rackspace import _id_translator
12 import nova.api.rackspace
13 import nova.image.service
14 from nova.api.rackspace import faults
15
16+
17+FLAGS = flags.FLAGS
18+
19 class Controller(wsgi.Controller):
20
21 _serialization_metadata = {
22@@ -35,7 +40,7 @@
23 }
24
25 def __init__(self):
26- self._service = nova.image.service.ImageService.load()
27+ self._service = utils.import_object(FLAGS.image_service)
28 self._id_translator = _id_translator.RackspaceAPIIdTranslator(
29 "image", self._service.__class__.__name__)
30
31
32=== modified file 'nova/api/rackspace/servers.py'
33--- nova/api/rackspace/servers.py 2010-10-05 20:07:11 +0000
34+++ nova/api/rackspace/servers.py 2010-10-05 20:21:00 +0000
35@@ -42,7 +42,7 @@
36
37 def _image_service():
38 """ Helper method for initializing the image id translator """
39- service = nova.image.service.ImageService.load()
40+ service = utils.import_object(FLAGS.image_service)
41 return (service, _id_translator.RackspaceAPIIdTranslator(
42 "image", service.__class__.__name__))
43
44
45=== modified file 'nova/flags.py'
46--- nova/flags.py 2010-09-29 00:53:27 +0000
47+++ nova/flags.py 2010-10-05 20:21:00 +0000
48@@ -222,6 +222,10 @@
49 DEFINE_string('scheduler_manager', 'nova.scheduler.manager.SchedulerManager',
50 'Manager for scheduler')
51
52+# The service to use for image search and retrieval
53+DEFINE_string('image_service', 'nova.image.service.LocalImageService',
54+ 'The service to use for retrieving and searching for images.')
55+
56 DEFINE_string('host', socket.gethostname(),
57 'name of this node')
58
59
60=== modified file 'nova/image/service.py'
61--- nova/image/service.py 2010-09-13 15:53:53 +0000
62+++ nova/image/service.py 2010-10-05 20:21:00 +0000
63@@ -16,38 +16,215 @@
64 # under the License.
65
66 import cPickle as pickle
67+import httplib
68+import json
69+import logging
70 import os.path
71 import random
72 import string
73-
74-class ImageService(object):
75- """Provides storage and retrieval of disk image objects."""
76-
77- @staticmethod
78- def load():
79- """Factory method to return image service."""
80- #TODO(gundlach): read from config.
81- class_ = LocalImageService
82- return class_()
83+import urlparse
84+
85+import webob.exc
86+
87+from nova import utils
88+from nova import flags
89+from nova import exception
90+
91+
92+FLAGS = flags.FLAGS
93+
94+
95+flags.DEFINE_string('glance_teller_address', 'http://127.0.0.1',
96+ 'IP address or URL where Glance\'s Teller service resides')
97+flags.DEFINE_string('glance_teller_port', '9191',
98+ 'Port for Glance\'s Teller service')
99+flags.DEFINE_string('glance_parallax_address', 'http://127.0.0.1',
100+ 'IP address or URL where Glance\'s Parallax service resides')
101+flags.DEFINE_string('glance_parallax_port', '9292',
102+ 'Port for Glance\'s Parallax service')
103+
104+
105+class BaseImageService(object):
106+
107+ """Base class for providing image search and retrieval services"""
108
109 def index(self):
110 """
111 Return a dict from opaque image id to image data.
112 """
113+ raise NotImplementedError
114
115 def show(self, id):
116 """
117 Returns a dict containing image data for the given opaque image id.
118- """
119-
120-
121-class GlanceImageService(ImageService):
122+
123+ :raises NotFound if the image does not exist
124+ """
125+ raise NotImplementedError
126+
127+ def create(self, data):
128+ """
129+ Store the image data and return the new image id.
130+
131+ :raises AlreadyExists if the image already exist.
132+
133+ """
134+ raise NotImplementedError
135+
136+ def update(self, image_id, data):
137+ """Replace the contents of the given image with the new data.
138+
139+ :raises NotFound if the image does not exist.
140+
141+ """
142+ raise NotImplementedError
143+
144+ def delete(self, image_id):
145+ """
146+ Delete the given image.
147+
148+ :raises NotFound if the image does not exist.
149+
150+ """
151+ raise NotImplementedError
152+
153+
154+class TellerClient(object):
155+
156+ def __init__(self):
157+ self.address = FLAGS.glance_teller_address
158+ self.port = FLAGS.glance_teller_port
159+ url = urlparse.urlparse(self.address)
160+ self.netloc = url.netloc
161+ self.connection_type = {'http': httplib.HTTPConnection,
162+ 'https': httplib.HTTPSConnection}[url.scheme]
163+
164+
165+class ParallaxClient(object):
166+
167+ def __init__(self):
168+ self.address = FLAGS.glance_parallax_address
169+ self.port = FLAGS.glance_parallax_port
170+ url = urlparse.urlparse(self.address)
171+ self.netloc = url.netloc
172+ self.connection_type = {'http': httplib.HTTPConnection,
173+ 'https': httplib.HTTPSConnection}[url.scheme]
174+
175+ def get_images(self):
176+ """
177+ Returns a list of image data mappings from Parallax
178+ """
179+ try:
180+ c = self.connection_type(self.netloc, self.port)
181+ c.request("GET", "images")
182+ res = c.getresponse()
183+ if res.status == 200:
184+ # Parallax returns a JSONified dict(images=image_list)
185+ data = json.loads(res.read())['images']
186+ return data
187+ else:
188+ logging.warn("Parallax returned HTTP error %d from "
189+ "request for /images", res.status_int)
190+ return []
191+ finally:
192+ c.close()
193+
194+ def get_image_metadata(self, image_id):
195+ """
196+ Returns a mapping of image metadata from Parallax
197+ """
198+ try:
199+ c = self.connection_type(self.netloc, self.port)
200+ c.request("GET", "images/%s" % image_id)
201+ res = c.getresponse()
202+ if res.status == 200:
203+ # Parallax returns a JSONified dict(image=image_info)
204+ data = json.loads(res.read())['image']
205+ return data
206+ else:
207+ # TODO(jaypipes): log the error?
208+ return None
209+ finally:
210+ c.close()
211+
212+ def add_image_metadata(self, image_metadata):
213+ """
214+ Tells parallax about an image's metadata
215+ """
216+ pass
217+
218+ def update_image_metadata(self, image_id, image_metadata):
219+ """
220+ Updates Parallax's information about an image
221+ """
222+ pass
223+
224+ def delete_image_metadata(self, image_id):
225+ """
226+ Deletes Parallax's information about an image
227+ """
228+ pass
229+
230+
231+class GlanceImageService(BaseImageService):
232+
233 """Provides storage and retrieval of disk image objects within Glance."""
234- # TODO(gundlach): once Glance has an API, build this.
235- pass
236-
237-
238-class LocalImageService(ImageService):
239+
240+ def __init__(self):
241+ self.teller = TellerClient()
242+ self.parallax = ParallaxClient()
243+
244+ def index(self):
245+ """
246+ Calls out to Parallax for a list of images available
247+ """
248+ images = self.parallax.get_images()
249+ return images
250+
251+ def show(self, id):
252+ """
253+ Returns a dict containing image data for the given opaque image id.
254+ """
255+ image = self.parallax.get_image_metadata(id)
256+ if image:
257+ return image
258+ raise exception.NotFound
259+
260+ def create(self, data):
261+ """
262+ Store the image data and return the new image id.
263+
264+ :raises AlreadyExists if the image already exist.
265+
266+ """
267+ return self.parallax.add_image_metadata(data)
268+
269+ def update(self, image_id, data):
270+ """Replace the contents of the given image with the new data.
271+
272+ :raises NotFound if the image does not exist.
273+
274+ """
275+ self.parallax.update_image_metadata(image_id, data)
276+
277+ def delete(self, image_id):
278+ """
279+ Delete the given image.
280+
281+ :raises NotFound if the image does not exist.
282+
283+ """
284+ self.parallax.delete_image_metadata(image_id)
285+
286+ def delete_all(self):
287+ """
288+ Clears out all images
289+ """
290+ pass
291+
292+
293+class LocalImageService(BaseImageService):
294+
295 """Image service storing images to local disk."""
296
297 def __init__(self):
298@@ -68,7 +245,10 @@
299 return [ self.show(id) for id in self._ids() ]
300
301 def show(self, id):
302- return pickle.load(open(self._path_to(id)))
303+ try:
304+ return pickle.load(open(self._path_to(id)))
305+ except IOError:
306+ raise exception.NotFound
307
308 def create(self, data):
309 """
310@@ -81,10 +261,23 @@
311
312 def update(self, image_id, data):
313 """Replace the contents of the given image with the new data."""
314- pickle.dump(data, open(self._path_to(image_id), 'w'))
315+ try:
316+ pickle.dump(data, open(self._path_to(image_id), 'w'))
317+ except IOError:
318+ raise exception.NotFound
319
320 def delete(self, image_id):
321 """
322 Delete the given image. Raises OSError if the image does not exist.
323 """
324- os.unlink(self._path_to(image_id))
325+ try:
326+ os.unlink(self._path_to(image_id))
327+ except IOError:
328+ raise exception.NotFound
329+
330+ def delete_all(self):
331+ """
332+ Clears out all images in local directory
333+ """
334+ for f in os.listdir(self._path):
335+ os.unlink(self._path_to(f))
336
337=== modified file 'nova/tests/api/rackspace/fakes.py'
338--- nova/tests/api/rackspace/fakes.py 2010-10-01 18:02:51 +0000
339+++ nova/tests/api/rackspace/fakes.py 2010-10-05 20:21:00 +0000
340@@ -1,5 +1,24 @@
341+# vim: tabstop=4 shiftwidth=4 softtabstop=4
342+
343+# Copyright 2010 OpenStack LLC.
344+# All Rights Reserved.
345+#
346+# Licensed under the Apache License, Version 2.0 (the "License"); you may
347+# not use this file except in compliance with the License. You may obtain
348+# a copy of the License at
349+#
350+# http://www.apache.org/licenses/LICENSE-2.0
351+#
352+# Unless required by applicable law or agreed to in writing, software
353+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
354+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
355+# License for the specific language governing permissions and limitations
356+# under the License.
357+
358 import datetime
359 import json
360+import random
361+import string
362
363 import webob
364 import webob.dec
365@@ -7,6 +26,7 @@
366 from nova import auth
367 from nova import utils
368 from nova import flags
369+from nova import exception as exc
370 import nova.api.rackspace.auth
371 import nova.api.rackspace._id_translator
372 from nova.image import service
373@@ -105,6 +125,60 @@
374 FLAGS.FAKE_subdomain = 'rs'
375
376
377+def stub_out_glance(stubs):
378+
379+ class FakeParallaxClient:
380+
381+ def __init__(self):
382+ self.fixtures = {}
383+
384+ def fake_get_images(self):
385+ return self.fixtures
386+
387+ def fake_get_image_metadata(self, image_id):
388+ for k, f in self.fixtures.iteritems():
389+ if k == image_id:
390+ return f
391+ return None
392+
393+ def fake_add_image_metadata(self, image_data):
394+ id = ''.join(random.choice(string.letters) for _ in range(20))
395+ image_data['id'] = id
396+ self.fixtures[id] = image_data
397+ return id
398+
399+ def fake_update_image_metadata(self, image_id, image_data):
400+
401+ if image_id not in self.fixtures.keys():
402+ raise exc.NotFound
403+
404+ self.fixtures[image_id].update(image_data)
405+
406+ def fake_delete_image_metadata(self, image_id):
407+
408+ if image_id not in self.fixtures.keys():
409+ raise exc.NotFound
410+
411+ del self.fixtures[image_id]
412+
413+ def fake_delete_all(self):
414+ self.fixtures = {}
415+
416+ fake_parallax_client = FakeParallaxClient()
417+ stubs.Set(nova.image.service.ParallaxClient, 'get_images',
418+ fake_parallax_client.fake_get_images)
419+ stubs.Set(nova.image.service.ParallaxClient, 'get_image_metadata',
420+ fake_parallax_client.fake_get_image_metadata)
421+ stubs.Set(nova.image.service.ParallaxClient, 'add_image_metadata',
422+ fake_parallax_client.fake_add_image_metadata)
423+ stubs.Set(nova.image.service.ParallaxClient, 'update_image_metadata',
424+ fake_parallax_client.fake_update_image_metadata)
425+ stubs.Set(nova.image.service.ParallaxClient, 'delete_image_metadata',
426+ fake_parallax_client.fake_delete_image_metadata)
427+ stubs.Set(nova.image.service.GlanceImageService, 'delete_all',
428+ fake_parallax_client.fake_delete_all)
429+
430+
431 class FakeAuthDatabase(object):
432 data = {}
433
434
435=== modified file 'nova/tests/api/rackspace/test_images.py'
436--- nova/tests/api/rackspace/test_images.py 2010-10-01 18:02:51 +0000
437+++ nova/tests/api/rackspace/test_images.py 2010-10-05 20:21:00 +0000
438@@ -15,25 +15,127 @@
439 # License for the specific language governing permissions and limitations
440 # under the License.
441
442+import logging
443 import unittest
444
445 import stubout
446
447+from nova import exception
448+from nova import utils
449 from nova.api.rackspace import images
450-
451-
452-class ImagesTest(unittest.TestCase):
453- def setUp(self):
454- self.stubs = stubout.StubOutForTesting()
455-
456- def tearDown(self):
457- self.stubs.UnsetAll()
458-
459- def test_get_image_list(self):
460- pass
461-
462- def test_delete_image(self):
463- pass
464-
465- def test_create_image(self):
466- pass
467+from nova.tests.api.rackspace import fakes
468+
469+
470+class BaseImageServiceTests():
471+
472+ """Tasks to test for all image services"""
473+
474+ def test_create(self):
475+
476+ fixture = {'name': 'test image',
477+ 'updated': None,
478+ 'created': None,
479+ 'status': None,
480+ 'serverId': None,
481+ 'progress': None}
482+
483+ num_images = len(self.service.index())
484+
485+ id = self.service.create(fixture)
486+
487+ self.assertNotEquals(None, id)
488+ self.assertEquals(num_images + 1, len(self.service.index()))
489+
490+ def test_create_and_show_non_existing_image(self):
491+
492+ fixture = {'name': 'test image',
493+ 'updated': None,
494+ 'created': None,
495+ 'status': None,
496+ 'serverId': None,
497+ 'progress': None}
498+
499+ num_images = len(self.service.index())
500+
501+ id = self.service.create(fixture)
502+
503+ self.assertNotEquals(None, id)
504+
505+ self.assertRaises(exception.NotFound,
506+ self.service.show,
507+ 'bad image id')
508+
509+ def test_update(self):
510+
511+ fixture = {'name': 'test image',
512+ 'updated': None,
513+ 'created': None,
514+ 'status': None,
515+ 'serverId': None,
516+ 'progress': None}
517+
518+ id = self.service.create(fixture)
519+
520+ fixture['status'] = 'in progress'
521+
522+ self.service.update(id, fixture)
523+ new_image_data = self.service.show(id)
524+ self.assertEquals('in progress', new_image_data['status'])
525+
526+ def test_delete(self):
527+
528+ fixtures = [
529+ {'name': 'test image 1',
530+ 'updated': None,
531+ 'created': None,
532+ 'status': None,
533+ 'serverId': None,
534+ 'progress': None},
535+ {'name': 'test image 2',
536+ 'updated': None,
537+ 'created': None,
538+ 'status': None,
539+ 'serverId': None,
540+ 'progress': None}]
541+
542+ ids = []
543+ for fixture in fixtures:
544+ new_id = self.service.create(fixture)
545+ ids.append(new_id)
546+
547+ num_images = len(self.service.index())
548+ self.assertEquals(2, num_images)
549+
550+ self.service.delete(ids[0])
551+
552+ num_images = len(self.service.index())
553+ self.assertEquals(1, num_images)
554+
555+
556+class LocalImageServiceTest(unittest.TestCase,
557+ BaseImageServiceTests):
558+
559+ """Tests the local image service"""
560+
561+ def setUp(self):
562+ self.stubs = stubout.StubOutForTesting()
563+ self.service = utils.import_object('nova.image.service.LocalImageService')
564+
565+ def tearDown(self):
566+ self.service.delete_all()
567+ self.stubs.UnsetAll()
568+
569+
570+class GlanceImageServiceTest(unittest.TestCase,
571+ BaseImageServiceTests):
572+
573+ """Tests the local image service"""
574+
575+ def setUp(self):
576+ self.stubs = stubout.StubOutForTesting()
577+ fakes.stub_out_glance(self.stubs)
578+ self.service = utils.import_object('nova.image.service.GlanceImageService')
579+
580+ def tearDown(self):
581+ self.service.delete_all()
582+ self.stubs.UnsetAll()
583
584=== modified file 'nova/tests/api/rackspace/test_servers.py'
585--- nova/tests/api/rackspace/test_servers.py 2010-10-05 19:37:15 +0000
586+++ nova/tests/api/rackspace/test_servers.py 2010-10-05 20:21:00 +0000
587@@ -33,6 +33,7 @@
588
589 FLAGS = flags.FLAGS
590
591+FLAGS.verbose = True
592
593 def return_server(context, id):
594 return stub_instance(id)