Merge lp:~jaypipes/glance/parallax-register-image into lp:~hudson-openstack/glance/trunk

Proposed by Jay Pipes
Status: Merged
Approved by: Michael Gundlach
Approved revision: 15
Merged at revision: 14
Proposed branch: lp:~jaypipes/glance/parallax-register-image
Merge into: lp:~hudson-openstack/glance/trunk
Diff against target: 515 lines (+310/-37)
8 files modified
glance/common/wsgi.py (+0/-1)
glance/parallax/controllers.py (+58/-15)
glance/parallax/db/sqlalchemy/api.py (+1/-1)
glance/parallax/db/sqlalchemy/models.py (+17/-15)
glance/teller/controllers.py (+1/-1)
run_tests.sh (+4/-4)
tests/stubs.py (+87/-0)
tests/unit/test_parallax_api.py (+142/-0)
To merge this branch: bzr merge lp:~jaypipes/glance/parallax-register-image
Reviewer Review Type Date Requested Status
Michael Gundlach (community) Approve
Rick Harris (community) Approve
Review via email: mp+38258@code.launchpad.net

Description of the change

Implements Parallax API call to register a new image

* Adds unit test for Parallax API controller
* Adds stubouts for glance.parallax.db.sqlalchemy.api calls
  regarding images
* Adds unittest for bad status on image creation
* Adds --logging-clear-handlers arg to nosetests in run_tests.sh to
  prevent extra output in running tests when tests complete successfully

To post a comment you must log in.
Revision history for this message
Michael Gundlach (gundlach) wrote :

Why does ImageController.__init__ exist?

57: should be
  if 'status' not in image_data:
or possibly 56/57/58 could be replaced with
  image_data.setdefault('status', 'available')

101: i assume you meant to remove support for GET /images/detail.

Otherwise, lgtm.

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

> Why does ImageController.__init__ exist?

lol. :) Leftover from when I had some debugging statements in there. ::blush::

> 57: should be
> if 'status' not in image_data:
> or possibly 56/57/58 could be replaced with
> image_data.setdefault('status', 'available')

Ah, nice. Thanks.

> 101: i assume you meant to remove support for GET /images/detail.

Yes, that does not comply with the Atom publishing protocol. No need for it AFAICT.

> Otherwise, lgtm.

Thanks! I'll push those little fixes later today :)

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

On Tue, Oct 12, 2010 at 4:36 PM, Jay Pipes <email address hidden> wrote:

> > 101: i assume you meant to remove support for GET /images/detail.
>
> Yes, that does not comply with the Atom publishing protocol. No need for
> it AFAICT.
>

We need it in order to comply as much as possible with the current Rackspace
API. GET /images should just return id and name, while /images/detail
returns all other available data. (same for GET /images/4 and GET
/images/4/detail .)

I think our goal was to conform to the RS API, not to conform to the Atom
protocol. Then again, we're planning to iterate on the API shortly after
Austin, so I'm not positive what this buys us.

cc'ing a couple of people from whom I've heard the "conform to the RS API"
message, to get their second opinions.

Michael

Revision history for this message
Rick Clark (dendrobates) wrote :

On 10/13/2010 11:00 AM, Michael Gundlach wrote:
> I think our goal was to conform to the RS API, not to conform to the Atom
> protocol. Then again, we're planning to iterate on the API shortly after
> Austin, so I'm not positive what this buys us.

We need to conform as much as possible to the RS API until they release
1.1 and hand off API development to us. At that point we can
diverge/fix in future versions.

I am not commenting on the technical merits of this discussion though.

> cc'ing a couple of people from whom I've heard the "conform to the RS API"
> message, to get their second opinions.
>
> Michael
>

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

++ on gundlach's cleanup suggestions, otherwise, lgtm.

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

cool, understood as far as RS API. fix coming shortly.

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

Alrighty, added the /images/detail call, a unit test for it, and gundlach's cleanups. Please mark the merge prop Approved if all meets expectations. :)

15. By Jay Pipes

Adds a /images/detail route to the Parallax controller, adds a unit test for it, and cleans up Michael's suggestions.

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

yeehaw

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'glance/common/wsgi.py'
2--- glance/common/wsgi.py 2010-10-01 23:07:46 +0000
3+++ glance/common/wsgi.py 2010-10-13 18:50:33 +0000
4@@ -224,7 +224,6 @@
5 Uses self._serialization_metadata if it exists, which is a dict mapping
6 MIME types to information needed to serialize to that type.
7 """
8- # FIXME(sirp): type(self) should just be `self`
9 _metadata = getattr(type(self), "_serialization_metadata", {})
10 serializer = Serializer(request.environ, _metadata)
11 return serializer.to_content_type(data)
12
13=== modified file 'glance/parallax/controllers.py'
14--- glance/parallax/controllers.py 2010-10-11 18:28:35 +0000
15+++ glance/parallax/controllers.py 2010-10-13 18:50:33 +0000
16@@ -18,26 +18,52 @@
17 Parllax Image controller
18 """
19
20+import json
21 import routes
22+from webob import exc
23+
24 from glance.common import wsgi
25 from glance.common import exception
26 from glance.parallax import db
27-from webob import exc
28
29
30 class ImageController(wsgi.Controller):
31+
32 """Image Controller """
33
34 def index(self, req):
35- """Return data for all public, non-deleted images """
36+ """Return basic information for all public, non-deleted images
37+
38+ :param req: the Request object coming from the wsgi layer
39+ :retval a mapping of the following form::
40+
41+ dict(images=[image_list])
42+
43+ Where image_list is a sequence of mappings::
44+
45+ {'id': image_id, 'name': image_name}
46+
47+ """
48+ images = db.image_get_all_public(None)
49+ image_dicts = [dict(id=i['id'], name=i['name']) for i in images]
50+ return dict(images=image_dicts)
51+
52+ def detail(self, req):
53+ """Return detailed information for all public, non-deleted images
54+
55+ :param req: the Request object coming from the wsgi layer
56+ :retval a mapping of the following form::
57+
58+ dict(images=[image_list])
59+
60+ Where image_list is a sequence of mappings containing
61+ all image model fields.
62+
63+ """
64 images = db.image_get_all_public(None)
65 image_dicts = [self._make_image_dict(i) for i in images]
66 return dict(images=image_dicts)
67
68- def detail(self, req):
69- """Detail is not currently supported """
70- raise exc.HTTPNotImplemented()
71-
72 def show(self, req, id):
73 """Return data about the given image id."""
74 try:
75@@ -52,8 +78,24 @@
76 raise exc.HTTPNotImplemented()
77
78 def create(self, req):
79- """Create is not currently supported """
80- raise exc.HTTPNotImplemented()
81+ """Registers a new image with the registry.
82+
83+ :param req: Request body. A JSON-ified dict of information about
84+ the image.
85+
86+ :retval Returns the newly-created image information as a mapping,
87+ which will include the newly-created image's internal id
88+ in the 'id' field
89+
90+ """
91+ image_data = json.loads(req.body)
92+
93+ # Ensure the image has a status set
94+ image_data.setdefault('status', 'available')
95+
96+ context = None
97+ new_image = db.image_create(context, image_data)
98+ return dict(new_image)
99
100 def update(self, req, id):
101 """Update is not currently supported """
102@@ -64,23 +106,23 @@
103 """ Create a dict represenation of an image which we can use to
104 serialize the image.
105 """
106- def _fetch_attrs(obj, attrs):
107- return dict([(a, getattr(obj, a)) for a in attrs])
108+ def _fetch_attrs(d, attrs):
109+ return dict([(a, d[a]) for a in attrs])
110
111 # attributes common to all models
112 base_attrs = set(['id', 'created_at', 'updated_at', 'deleted_at',
113 'deleted'])
114
115 file_attrs = base_attrs | set(['location', 'size'])
116- files = [_fetch_attrs(f, file_attrs) for f in image.files]
117+ files = [_fetch_attrs(f, file_attrs) for f in image['files']]
118
119 # TODO(sirp): should this be a dict, or a list of dicts?
120 # A plain dict is more convenient, but list of dicts would provide
121 # access to created_at, etc
122- metadata = dict((m.key, m.value) for m in image.metadata
123- if not m.deleted)
124+ metadata = dict((m['key'], m['value']) for m in image['metadata']
125+ if not m['deleted'])
126
127- image_attrs = base_attrs | set(['name', 'image_type', 'state', 'public'])
128+ image_attrs = base_attrs | set(['name', 'image_type', 'status', 'is_public'])
129 image_dict = _fetch_attrs(image, image_attrs)
130
131 image_dict['files'] = files
132@@ -95,5 +137,6 @@
133 # TODO(sirp): should we add back the middleware for parallax?
134 mapper = routes.Mapper()
135 mapper.resource("image", "images", controller=ImageController(),
136- collection={'detail': 'GET'})
137+ collection={'detail': 'GET'})
138+ mapper.connect("/", controller=ImageController(), action="index")
139 super(API, self).__init__(mapper)
140
141=== modified file 'glance/parallax/db/sqlalchemy/api.py'
142--- glance/parallax/db/sqlalchemy/api.py 2010-10-01 20:27:48 +0000
143+++ glance/parallax/db/sqlalchemy/api.py 2010-10-13 18:50:33 +0000
144@@ -95,7 +95,7 @@
145 ).options(joinedload(models.Image.files)
146 ).options(joinedload(models.Image.metadata)
147 ).filter_by(deleted=_deleted(context)
148- ).filter_by(public=public
149+ ).filter_by(is_public=public
150 ).all()
151
152
153
154=== modified file 'glance/parallax/db/sqlalchemy/models.py'
155--- glance/parallax/db/sqlalchemy/models.py 2010-10-01 19:51:29 +0000
156+++ glance/parallax/db/sqlalchemy/models.py 2010-10-13 18:50:33 +0000
157@@ -23,16 +23,13 @@
158 import sys
159 import datetime
160
161-# TODO(vish): clean up these imports
162-from sqlalchemy.orm import relationship, backref, exc, object_mapper
163+from sqlalchemy.orm import relationship, backref, exc, object_mapper, validates
164 from sqlalchemy import Column, Integer, String
165 from sqlalchemy import ForeignKey, DateTime, Boolean, Text
166 from sqlalchemy.ext.declarative import declarative_base
167
168 from glance.common.db.sqlalchemy.session import get_session
169
170-# FIXME(sirp): confirm this is not needed
171-#from common import auth
172 from glance.common import exception
173 from glance.common import flags
174
175@@ -40,6 +37,7 @@
176
177 BASE = declarative_base()
178
179+
180 #TODO(sirp): ModelBase should be moved out so Glance and Nova can share it
181 class ModelBase(object):
182 """Base class for Nova and Glance Models"""
183@@ -128,18 +126,22 @@
184
185 id = Column(Integer, primary_key=True)
186 name = Column(String(255))
187- image_type = Column(String(255))
188- state = Column(String(255))
189- public = Column(Boolean, default=False)
190+ image_type = Column(String(30))
191+ status = Column(String(30))
192+ is_public = Column(Boolean, default=False)
193
194- #@validates('image_type')
195- #def validate_image_type(self, key, image_type):
196- # assert(image_type in ('machine', 'kernel', 'ramdisk', 'raw'))
197- #
198- #@validates('state')
199- #def validate_state(self, key, state):
200- # assert(state in ('available', 'pending', 'disabled'))
201- #
202+ @validates('image_type')
203+ def validate_image_type(self, key, image_type):
204+ if not image_type in ('machine', 'kernel', 'ramdisk', 'raw'):
205+ raise exception.Invalid("Invalid image type '%s' for image." % image_type)
206+ return image_type
207+
208+ @validates('status')
209+ def validate_status(self, key, state):
210+ if not state in ('available', 'pending', 'disabled'):
211+ raise exception.Invalid("Invalid status '%s' for image." % status)
212+ return image_type
213+
214 # TODO(sirp): should these be stored as metadata?
215 #user_id = Column(String(255))
216 #project_id = Column(String(255))
217
218=== modified file 'glance/teller/controllers.py'
219--- glance/teller/controllers.py 2010-10-11 18:28:35 +0000
220+++ glance/teller/controllers.py 2010-10-13 18:50:33 +0000
221@@ -95,7 +95,7 @@
222
223
224 class API(wsgi.Router):
225- """WSGI entry point for all Parallax requests."""
226+ """WSGI entry point for all Teller requests."""
227
228 def __init__(self):
229 mapper = routes.Mapper()
230
231=== modified file 'run_tests.sh'
232--- run_tests.sh 2010-10-08 20:42:13 +0000
233+++ run_tests.sh 2010-10-13 18:50:33 +0000
234@@ -41,12 +41,12 @@
235
236 if [ $never_venv -eq 1 ]; then
237 # Just run the test suites in current environment
238- python run_tests.py
239+ nosetests --logging-clear-handlers
240 exit
241 fi
242
243 if [ -e ${venv} ]; then
244- ${with_venv} nosetests
245+ ${with_venv} nosetests --logging-clear-handlers
246 else
247 if [ $always_venv -eq 1 ]; then
248 # Automatically install the virtualenv
249@@ -58,9 +58,9 @@
250 # Install the virtualenv and run the test suite in it
251 python tools/install_venv.py
252 else
253- nosetests
254+ nosetests --logging-clear-handlers
255 exit
256 fi
257 fi
258- ${with_venv} nosetests
259+ ${with_venv} nosetests --logging-clear-handlers
260 fi
261
262=== modified file 'tests/stubs.py'
263--- tests/stubs.py 2010-10-11 18:28:35 +0000
264+++ tests/stubs.py 2010-10-13 18:50:33 +0000
265@@ -17,12 +17,15 @@
266
267 """Stubouts, mocks and fixtures for the test suite"""
268
269+import datetime
270 import httplib
271 import StringIO
272
273 import stubout
274
275+from glance.common import exception
276 import glance.teller.backends.swift
277+import glance.parallax.db.sqlalchemy.api
278
279 def stub_out_http_backend(stubs):
280 """Stubs out the httplib.HTTPRequest.getresponse to return
281@@ -147,3 +150,87 @@
282 fake_parallax_registry = FakeParallax()
283 stubs.Set(glance.teller.registries.Parallax, 'lookup',
284 fake_parallax_registry.lookup)
285+
286+
287+def stub_out_parallax_db_image_api(stubs):
288+ """Stubs out the database set/fetch API calls for Parallax
289+ so the calls are routed to an in-memory dict. This helps us
290+ avoid having to manually clear or flush the SQLite database.
291+
292+ The "datastore" always starts with this set of image fixtures.
293+
294+ :param stubs: Set of stubout stubs
295+
296+ """
297+ class FakeDatastore(object):
298+
299+ FIXTURES = [
300+ {'id': 1,
301+ 'name': 'fake image #1',
302+ 'status': 'available',
303+ 'image_type': 'kernel',
304+ 'is_public': False,
305+ 'created_at': datetime.datetime.utcnow(),
306+ 'updated_at': datetime.datetime.utcnow(),
307+ 'deleted_at': None,
308+ 'deleted': False,
309+ 'files': [],
310+ 'metadata': []},
311+ {'id': 2,
312+ 'name': 'fake image #2',
313+ 'status': 'available',
314+ 'image_type': 'kernel',
315+ 'is_public': True,
316+ 'created_at': datetime.datetime.utcnow(),
317+ 'updated_at': datetime.datetime.utcnow(),
318+ 'deleted_at': None,
319+ 'deleted': False,
320+ 'files': [],
321+ 'metadata': []}]
322+
323+ VALID_STATUSES = ('available', 'disabled', 'pending')
324+
325+ def __init__(self):
326+ self.images = self.FIXTURES
327+ self.next_id = 3
328+
329+ def image_create(self, _context, values):
330+ values['id'] = self.next_id
331+
332+ if 'status' not in values.keys():
333+ values['status'] = 'available'
334+ else:
335+ if not values['status'] in self.VALID_STATUSES:
336+ raise exception.Invalid("Invalid status '%s' for image" % values['status'])
337+
338+ self.next_id += 1
339+ self.images.extend(values)
340+ return values
341+
342+ def image_destroy(self, _context, image_id):
343+ try:
344+ del self.images[image_id]
345+ except KeyError:
346+ new_exc = exception.NotFound("No model for id %s" % image_id)
347+ raise new_exc.__class__, new_exc, sys.exc_info()[2]
348+
349+ def image_get(self, _context, image_id):
350+ if image_id not in self.images.keys() or self.images[image_id]['deleted']:
351+ new_exc = exception.NotFound("No model for id %s" % image_id)
352+ raise new_exc.__class__, new_exc, sys.exc_info()[2]
353+ else:
354+ return self.images[image_id]
355+
356+ def image_get_all_public(self, _context, public):
357+ return [f for f in self.images
358+ if f['is_public'] == public]
359+
360+ fake_datastore = FakeDatastore()
361+ stubs.Set(glance.parallax.db.sqlalchemy.api, 'image_create',
362+ fake_datastore.image_create)
363+ stubs.Set(glance.parallax.db.sqlalchemy.api, 'image_destroy',
364+ fake_datastore.image_destroy)
365+ stubs.Set(glance.parallax.db.sqlalchemy.api, 'image_get',
366+ fake_datastore.image_get)
367+ stubs.Set(glance.parallax.db.sqlalchemy.api, 'image_get_all_public',
368+ fake_datastore.image_get_all_public)
369
370=== added file 'tests/unit/test_parallax_api.py'
371--- tests/unit/test_parallax_api.py 1970-01-01 00:00:00 +0000
372+++ tests/unit/test_parallax_api.py 2010-10-13 18:50:33 +0000
373@@ -0,0 +1,142 @@
374+# vim: tabstop=4 shiftwidth=4 softtabstop=4
375+
376+# Copyright 2010 OpenStack, LLC
377+# All Rights Reserved.
378+#
379+# Licensed under the Apache License, Version 2.0 (the "License"); you may
380+# not use this file except in compliance with the License. You may obtain
381+# a copy of the License at
382+#
383+# http://www.apache.org/licenses/LICENSE-2.0
384+#
385+# Unless required by applicable law or agreed to in writing, software
386+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
387+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
388+# License for the specific language governing permissions and limitations
389+# under the License.
390+
391+import json
392+import stubout
393+import unittest
394+import webob
395+
396+from glance.common import exception
397+from glance.parallax import controllers
398+from glance.parallax import db
399+from tests import stubs
400+
401+
402+class TestImageController(unittest.TestCase):
403+ def setUp(self):
404+ """Establish a clean test environment"""
405+ self.stubs = stubout.StubOutForTesting()
406+ self.image_controller = controllers.ImageController()
407+ stubs.stub_out_parallax_db_image_api(self.stubs)
408+
409+ def tearDown(self):
410+ """Clear the test environment"""
411+ self.stubs.UnsetAll()
412+
413+ def test_get_root(self):
414+ """Tests that the root parallax API returns "index",
415+ which is a list of public images
416+
417+ """
418+ fixture = {'id': 2,
419+ 'name': 'fake image #2'}
420+ req = webob.Request.blank('/')
421+ res = req.get_response(controllers.API())
422+ res_dict = json.loads(res.body)
423+ self.assertEquals(res.status_int, 200)
424+
425+ images = res_dict['images']
426+ self.assertEquals(len(images), 1)
427+
428+ for k,v in fixture.iteritems():
429+ self.assertEquals(v, images[0][k])
430+
431+ def test_get_index(self):
432+ """Tests that the /images parallax API returns list of
433+ public images
434+
435+ """
436+ fixture = {'id': 2,
437+ 'name': 'fake image #2'}
438+ req = webob.Request.blank('/images')
439+ res = req.get_response(controllers.API())
440+ res_dict = json.loads(res.body)
441+ self.assertEquals(res.status_int, 200)
442+
443+ images = res_dict['images']
444+ self.assertEquals(len(images), 1)
445+
446+ for k,v in fixture.iteritems():
447+ self.assertEquals(v, images[0][k])
448+
449+ def test_get_details(self):
450+ """Tests that the /images/detail parallax API returns
451+ a mapping containing a list of detailed image information
452+
453+ """
454+ fixture = {'id': 2,
455+ 'name': 'fake image #2',
456+ 'is_public': True,
457+ 'image_type': 'kernel',
458+ 'status': 'available'
459+ }
460+ req = webob.Request.blank('/images/detail')
461+ res = req.get_response(controllers.API())
462+ res_dict = json.loads(res.body)
463+ self.assertEquals(res.status_int, 200)
464+
465+ images = res_dict['images']
466+ self.assertEquals(len(images), 1)
467+
468+ for k,v in fixture.iteritems():
469+ self.assertEquals(v, images[0][k])
470+
471+ def test_create_image(self):
472+ """Tests that the /images POST parallax API creates the image"""
473+ fixture = {'name': 'fake public image',
474+ 'is_public': True,
475+ 'image_type': 'kernel'
476+ }
477+
478+ req = webob.Request.blank('/images')
479+
480+ req.method = 'POST'
481+ req.body = json.dumps(fixture)
482+
483+ res = req.get_response(controllers.API())
484+
485+ self.assertEquals(res.status_int, 200)
486+
487+ res_dict = json.loads(res.body)
488+
489+ for k,v in fixture.iteritems():
490+ self.assertEquals(v, res_dict[k])
491+
492+ # Test ID auto-assigned properly
493+ self.assertEquals(3, res_dict['id'])
494+
495+ # Test status was updated properly
496+ self.assertEquals('available', res_dict['status'])
497+
498+ def test_create_image_with_bad_status(self):
499+ """Tests proper exception is raised if a bad status is set"""
500+ fixture = {'id': 3,
501+ 'name': 'fake public image',
502+ 'is_public': True,
503+ 'image_type': 'kernel',
504+ 'status': 'bad status'
505+ }
506+
507+ req = webob.Request.blank('/images')
508+
509+ req.method = 'POST'
510+ req.body = json.dumps(fixture)
511+
512+ # TODO(jaypipes): Port Nova's Fault infrastructure
513+ # over to Glance to support exception catching into
514+ # standard HTTP errors.
515+ self.assertRaises(exception.Invalid, req.get_response, controllers.API())

Subscribers

People subscribed via source and target branches