Merge lp:~blamar/nova/openstack-api-1-1-images into lp:~hudson-openstack/nova/trunk

Proposed by Brian Lamar
Status: Merged
Merged at revision: 907
Proposed branch: lp:~blamar/nova/openstack-api-1-1-images
Merge into: lp:~hudson-openstack/nova/trunk
Prerequisite: lp:~rackspace-titan/nova/openstack-api-versioned-controllers
Diff against target: 986 lines (+595/-254)
4 files modified
nova/api/openstack/__init__.py (+8/-3)
nova/api/openstack/images.py (+102/-209)
nova/api/openstack/views/images.py (+91/-11)
nova/tests/api/openstack/test_images.py (+394/-31)
To merge this branch: bzr merge lp:~blamar/nova/openstack-api-1-1-images
Reviewer Review Type Date Requested Status
Jay Pipes (community) Approve
Thierry Carrez (community) ffe Approve
justinsb (community) Approve
termie (community) Needs Fixing
Rick Harris (community) Approve
Brian Waldon (community) Approve
Review via email: mp+53942@code.launchpad.net

Description of the change

Adds support for versioned requests on /images through the OpenStack API.

To post a comment you must log in.
Revision history for this message
Brian Waldon (bcwaldon) wrote :

Many of your method-level comments refer to 'webob.Request'. This should actually be 'wsgi.Request'.

155: The _builder_dispatch dict can probably go away now. _get_builder takes care of it.

209: Looks like you called get_builder and build incorrectly: "return self.get_builder().build(req, image, True)" should be "return self.get_builder(req).build(image, True)"

review: Needs Fixing
Revision history for this message
Brian Lamar (blamar) wrote :

Changes reflect your comments. I found out I wasn't even testing show(), which should now be fully tested.

Moving back to ready for review.

Revision history for this message
Brian Lamar (blamar) wrote :

Working on merging trunk this morning after a couple of relevant merges which happened last night. Shouldn't take long (hopefully).

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

Looking good! What would you think about changing the generate_href method from:
    "%s/images/%s" % (self._url, image_id)
to:
    os.path.join(self._url, "images", image_id)

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

I should also point out to anybody reviewing this that the prerequisite branch has been merged into trunk.

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

Hi! Good stuff! A few suggestions from me:

173 + self.__compute = compute_service or compute.API()
174 + self.__image = image_service or _default_service

Please use zero or a single underscore, not two, for attributes. One underscore indicates a "private" attribute/method (even though Python has no concept of a private attribute, really... Generally, two underscores indicate that the attribute/method is special to the system (like __dict__, etc).

154 - "serverId", "progress"]}}}
...
158 + "serverId", "progress"],
159 + "link": ["rel", "type", "href"],
160 + },
161 + },
162 + }

This will break pep8 0.5.0. Just an FYI. If you run your tests with ./run_tests.sh -V, you would see this pop up. Whether pep8 0.6 should be in tools/pip-requires is a totally separate issue, but until it is, running tests in a virtualenv will fail with stuff like the above. Frankly, I think this particular pep8 rule is stupid and makes the code less-readable, but until pep8 0.6 is the standard used in tools/pip-requires, there's not much I can do about that...

While the rest of the code looks fine, I'd like Rick H. to take a looksie, because I have the feeling that his related_images branch will cause this branch to conflict, and the related_images branch has a number of improvements in it re: how we handle OS API /images keys, image properties, and how the translation from image service dicts to OS API dicts works...

-jay

review: Needs Fixing
Revision history for this message
Brian Lamar (blamar) wrote :

Hey Jay! Thanks for the comments:

> Please use zero or a single underscore, not two, for attributes.

Two leading underscores is Python's way of avoiding conflict with related sub/super class variables through 'name mangling'. I use this pretty regularly in my personal projects which is why it slipped in to this change. (http://docs.python.org/tutorial/classes.html#private-variables)

While I'll remove it if you/others think it doesn't belong, I technically think that any class member variable which isn't being used outside of the class it's defined should be 'Python private' to avoid conflicts...but maybe it's just a personal thing, are there any PEPs associated with the use of privates?

> This will break pep8 0.5.0.

We had a good discussion the other day about using specific versions in tools/pip-requires and I'd like to bring it up again since the last time you changed my mind about specifying specific versions.

Can I file a ticket to increase this version or are we linking it to the latest package release in Ubuntu? If any program we use should be the latest version, IMO it should be pep8.

Revision history for this message
Brian Lamar (blamar) wrote :

> If you run your tests with ./run_tests.sh -V

My results from ./run_tests.sh -V show:

Ran 532 tests in 394.149s

OK

and no pep8 failures.

Revision history for this message
Brian Lamar (blamar) wrote :

After thinking about it I'm going to rename those variables to something more appropriate and use a single underscore. It's more in line with other styles used in the project and an overall improvement. Thanks Jay!

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

> related_images branch will cause this branch to conflict

Yeah we ended up touching a lot of the same code, so this will almost certainly conflict.

Brian--

could you take a ganders at `related_images`:
https://code.launchpad.net/~rconradharris/nova/related_images/+merge/53374
and see how much you think the two will conflict?

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

Thanks for addressing my concerns

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

On Thu, Mar 24, 2011 at 11:48 AM, Brian Lamar <email address hidden> wrote:
>> If you run your tests with ./run_tests.sh -V
>
> My results from ./run_tests.sh -V show:
>
> Ran 532 tests in 394.149s
>
> OK
>
> and no pep8 failures.

Heh, well, I must be out of my mind, then :) I ran your branch locally
and got this, totally non-related to this patch, error:

nova/tests/test_volume.py:359:63: E202 whitespace before ')'
                                    "--tid=%(tid)d" % locals()
                                                              ^
    Avoid extraneous whitespace in the following situations:

    - Immediately inside parentheses, brackets or braces.

    - Immediately before a comma, semicolon, or colon.

    Okay: spam(ham[1], {eggs: 2})
    E201: spam( ham[1], {eggs: 2})
    E201: spam(ham[ 1], {eggs: 2})
    E201: spam(ham[1], { eggs: 2})
    E202: spam(ham[1], {eggs: 2} )
    E202: spam(ham[1 ], {eggs: 2})
    E202: spam(ham[1], {eggs: 2 })

    E203: if x == 4: print x, y; x, y = y , x
    E203: if x == 4: print x, y ; x, y = y, x
    E203: if x == 4 : print x, y; x, y = y, x
jpipes@serialcoder:~/repos/nova/openstack-api-1-1-images$

It would *seem* that your code in /nova/api/openstack/images.py would
trigger the same error, but it doesn't! :)

-jay

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

Nice work Brian. A few comments:

General
=======

* Not much we can do here, but looks like this is gonna conflict pretty heavily with `related_images`

* I'm seeing quite a bit of dependency-injection being used. This can usually be avoid by stubbing out using Fakes and Mocks. This keeps the implementation clean since it won't have to take `client`, `image_service`, `compute_service` args. This isn't a deal-breaker for this patch (by any means), but wanted to raise it for discussion.

Specifics
=========

> 235 + ex = webob.exc.HTTPNotFound(explanation="Image not found.")

Needs i18n treatment.

> 238 + return dict(image=self.get_builder(req).build(image, True))

Might be a little clearer if a kwarg is used, like:

    return dict(image=self.get_builder(req).build(image, detail=True))

> 464 now = datetime.datetime.utcnow()

It's usually not a good idea to use now-now in tests, since the tests, in a way, change each time. For example, you can see a test pass everyday but fail on Feb. 29th. Rather, you might want to do something like:

    NOW_DATETIME = datetime.datetime(2010, 10, 11, 10, 32, 23)
    NOW_STR = "2010-10-11T10:32:23Z"

review: Needs Fixing
Revision history for this message
Brian Lamar (blamar) wrote :

Thanks for the input Rick.

Unfortunately you're right in that this is going to conflict very heavily with the work you have been doing. I'd love to discuss how this can be avoided in the future. I try to keep up on Nova blueprints and the associated branches when they sound API specific but I think that you were potentially not working off a blueprint?

> I'm seeing quite a bit of dependency-injection being used.

Wow, I've never been told that DI is bad, can you potentially give me more details?

> 235 + ex = webob.exc.HTTPNotFound(explanation="Image not found.")

Updated, good catch.

> 238 + return dict(image=self.get_builder(req).build(image, True))

I don't agree in the general case, but it's the second time someone has mentioned this specific case to me. This has been fixed.

> It's usually not a good idea to use now-now in tests.

I'm not quite sure I follow the logic here as none of the tests do time-based comparison (which I agree is a no-no). Can you elaborate?

Thanks!

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

> Wow, I've never been told that DI is bad, can you potentially give me more details?

Sure. DI is a pretty common pattern static languages since it gives you the flexibility to test against something other than the original implementation.

With a dynamic language like Python, you get this for free, no dependency injection needed. All you need to do is re-bind the dependent-class in the setUp of the test. I believe we have plenty of examples of this pattern across our test suite. Here's an example:

DI-Style
========

class Dependent(object):
  pass

class DoesSomething(object):
  def __init__(self, dependent=None)
    self.dependent = dependent or Dependent()

class FakeDependent(object):
  pass

# test it
test = DoesSomething(dependent=FakeDependent)

Non-DI-Style
============

class DoesSomething(object):
  def __init__(self)
    self.dependent = Dependent()

# test it
Dependent = FakeDependent() # Rebind, use the `stubs` library in the real case
test = DoesSomething()

Notice that both ways achieve the same thing, the code ultimately uses FakeDependent. But with the Non-DI style, we don't have to add N number of dependents to the constructor.

> I'm not quite sure I follow the logic here as none of the tests do time-based comparison

In this case, that's true. This is more of a suggestion as a best-practice: tests should be invariant over time. I'm just pointing out that there is a hidden dependency on time here which might be come a problem in the future as new tests are added.

FWIW, I've been burnt by this in the past, that's why I'm raising the concern here.

> think that you were potentially not working off a blueprint?

Yeah, my fault here :/ There *is* a blueprint but it's under Glance since that's where most of the work was planning to take place. However, it turned out, it was much cleaner if the changes were made to Nova; at that point I should created a Nova blueprint and rejected the Glance prop. My apologies.

Revision history for this message
Brian Lamar (blamar) wrote :

Potential Reviewers,

I request this branch be merged in as a Feature Freeze Exception due to it's link to an approved Cactus blueprint (OpenStack API v1.1). The branch took a great deal of merge-conflict resolution but now the /images resource should be version-compatible.

Branch Benefits
===============
I believe a goal of Cactus was to make the OpenStack API more stable and complete, which is what this branch helps. Myself and my team will be looking at *throughly* testing the OpenStack API up until the Cactus release to ensure API stability and accuracy. Many tests are already included in this branch, but we are still lacking complete integration and acceptance testing.

Regression Risk
===============
I'm not sure how to quantify this, but we have multiple branches which were merged using the same versioning patter I've used here (OpenStack API v1.1 Flavors and Servers come to mind). I would rate regression risk quite low.

Revision history for this message
Thierry Carrez (ttx) wrote :

FFe granted, but please get this in asap and before Tuesday night.

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

Nice work, Brian. LGTM.

Caveat: wasn't able to get tests to pass, but I think that was a result of my hosed env. Could be a while before I can get that rebuilt, so approving anyway.

One small nit:

> 1036 + print self.fixtures

Accidentally left in?

review: Approve
Revision history for this message
justinsb (justin-fathomdb) wrote :

V1.1 requires an imageRef when creating a server. Are callers now supposed to dig through the links collection to find the imageRef?

Do we have a schema for V1.1 that we can check this against?

This is exactly why I think we need a client that we're maintaining, by the way. Our API should be a joy, and the way we find out where the rough edges are is when we code to it.

review: Needs Fixing
Revision history for this message
Brian Lamar (blamar) wrote :

@justinsb

> V1.1 requires an imageRef when creating a server

Yes.

> Are callers now supposed to dig through the links collection to find the imageRef?

GET /v1.1/images/1 would return an image which has a list of "links". One of those links would be a "self" link with a fully qualified URL which can be used as "imageRef" in a server creation.

> Do we have a schema for V1.1 that we can check this against?

No.

> This is exactly why I think we need a client that we're maintaining, by the way. Our API should be
> a joy, and the way we find out where the rough edges are is when we code to it.

Great thought, it's not something that we have right now, but it's something I'd love to have. A full set of end-to-end API tests along with XML (and JSON?) schema validation would help the API tremendously.

Is there something about this code specifically you're troubled with? I'd love to work it out as the primary purpose of this branch is to bring versioning to the "images" resource.

Revision history for this message
termie (termie) wrote :

- please format docstrings according to HACKING
- in tests, don't import parseString directly from minidom, import the module instead, as referenced in HACKING, and hte space before it is not necessary

I'll bow out of commenting on the design as it looks like others have already discussed it

review: Needs Fixing
Revision history for this message
justinsb (justin-fathomdb) wrote :

> GET /v1.1/images/1 would return an image which has a list of "links". One of those links would be a "self" link with a fully qualified URL which can be used as "imageRef" in a server creation.

I think we should return the imageRef as a simple attribute, so that it's easy for callers. We're still returning the image ID, right, but that's now useless? Or are we supposed to use the ID when doing REST operations through the OpenStack API? But then why aren't we using the imageRef - do we now have two URLs for an image (which will often be the same, until they're not, when people that have assumed they're the same will break?) If we're admitting the possibility of external images by using an imageRef, why is there an images controller in the OpenStack API at all?

This isn't your fault, but I find the introduction of imageRefs very confusing. It feels like we're trying to mix a simple 'owned image' model with a more powerful 'federated images' model, but we don't seem to have thought that through and figured out a way to fuse the two nicely.

We need to get this merged, and I guess this is on-spec, so I'm going to change to Approve. I'm not a fan of the spec though.

If we could return the imageRef as a simple attribute so that clients don't have to dig through the atom links, that would make me happier. Normally that would break the XSD, but as we don't have one... :-)

review: Approve
Revision history for this message
Brian Lamar (blamar) wrote :

@termie

> please format docstrings according to HACKING

Thanks for the review! Can you give me a bit more of a push with regards to docstrings? Are all of them wrong? PEP-257 is pretty lenient as to docstrings, is the :param foo and :returns: format required? HACKING is a little vauge sometimes for me. Some things seem like format suggestions and not standard requirements. Thanks!

> in tests, don't import parseString directly from minidom, import the module instead

Good catch, I've aliased xml.dom.minidom to "minidom" as I find xml.dom.minidom.parseString a painfully long call, I hope that is acceptable. Changes are in r826 and pushed.

Thanks again!

Revision history for this message
Thierry Carrez (ttx) :
review: Approve (ffe)
Revision history for this message
Jay Pipes (jaypipes) :
review: Approve
Revision history for this message
Vish Ishaya (vishvananda) wrote :

'the :param foo and :returns: format' works well with sphinx

For short docstrings:

"""This is much nicer."""

"""
Than This.
"""

The format that nova is using for multiline strings is:

"""One line description.

Other Info.

"""

Revision history for this message
Brian Lamar (blamar) wrote :

@termie

Docstrings have been update, can you please review to ensure HACKING compatibility? Thanks!

Revision history for this message
Brian Lamar (blamar) wrote :

Merged trunk and fixed conflict.

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

Did this get messed up in merge?

52 +import webob.exc
53 import datetime
54
55 -from webob import exc

Should be:

import datetime

import webob.exc

-jay

review: Needs Fixing
Revision history for this message
Brian Lamar (blamar) wrote :

Hmm, nope. Pretty sure it just wasn't caught before. Fixed now.

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

ok by me.

review: Approve
Revision history for this message
OpenStack Infra (hudson-openstack) wrote :
Revision history for this message
termie (termie) wrote :

still missed a few, but i'm going to do a style sweep anyway so I guess no biggie for now.

Revision history for this message
Brian Lamar (blamar) wrote :

@termie

Can you *please* be more specific? It would really help me, especially with something as common as docstrings, to indicate specifics and line numbers when commenting. Thanks!

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'nova/api/openstack/__init__.py'
--- nova/api/openstack/__init__.py 2011-03-28 20:57:35 +0000
+++ nova/api/openstack/__init__.py 2011-03-29 16:12:48 +0000
@@ -111,9 +111,6 @@
111 parent_resource=dict(member_name='server',111 parent_resource=dict(member_name='server',
112 collection_name='servers'))112 collection_name='servers'))
113113
114 mapper.resource("image", "images", controller=images.Controller(),
115 collection={'detail': 'GET'})
116
117 _limits = limits.LimitsController()114 _limits = limits.LimitsController()
118 mapper.resource("limit", "limits", controller=_limits)115 mapper.resource("limit", "limits", controller=_limits)
119116
@@ -128,6 +125,10 @@
128 collection={'detail': 'GET'},125 collection={'detail': 'GET'},
129 member=self.server_members)126 member=self.server_members)
130127
128 mapper.resource("image", "images",
129 controller=images.ControllerV10(),
130 collection={'detail': 'GET'})
131
131 mapper.resource("flavor", "flavors",132 mapper.resource("flavor", "flavors",
132 controller=flavors.ControllerV10(),133 controller=flavors.ControllerV10(),
133 collection={'detail': 'GET'})134 collection={'detail': 'GET'})
@@ -152,6 +153,10 @@
152 collection={'detail': 'GET'},153 collection={'detail': 'GET'},
153 member=self.server_members)154 member=self.server_members)
154155
156 mapper.resource("image", "images",
157 controller=images.ControllerV11(),
158 collection={'detail': 'GET'})
159
155 mapper.resource("image_meta", "meta",160 mapper.resource("image_meta", "meta",
156 controller=image_metadata.Controller(),161 controller=image_metadata.Controller(),
157 parent_resource=dict(member_name='image',162 parent_resource=dict(member_name='image',
158163
=== modified file 'nova/api/openstack/images.py'
--- nova/api/openstack/images.py 2011-03-24 21:13:55 +0000
+++ nova/api/openstack/images.py 2011-03-29 16:12:48 +0000
@@ -1,6 +1,4 @@
1# vim: tabstop=4 shiftwidth=4 softtabstop=41# Copyright 2011 OpenStack LLC.
2
3# Copyright 2010 OpenStack LLC.
4# All Rights Reserved.2# All Rights Reserved.
5#3#
6# Licensed under the Apache License, Version 2.0 (the "License"); you may4# Licensed under the Apache License, Version 2.0 (the "License"); you may
@@ -17,7 +15,7 @@
1715
18import datetime16import datetime
1917
20from webob import exc18import webob.exc
2119
22from nova import compute20from nova import compute
23from nova import exception21from nova import exception
@@ -25,238 +23,133 @@
25from nova import log23from nova import log
26from nova import utils24from nova import utils
27from nova import wsgi25from nova import wsgi
28import nova.api.openstack
29from nova.api.openstack import common26from nova.api.openstack import common
30from nova.api.openstack import faults27from nova.api.openstack import faults
31import nova.image.service28from nova.api.openstack.views import images as images_view
3229
3330
34LOG = log.getLogger('nova.api.openstack.images')31LOG = log.getLogger('nova.api.openstack.images')
35
36FLAGS = flags.FLAGS32FLAGS = flags.FLAGS
3733
3834
39def _translate_keys(item):
40 """
41 Maps key names to Rackspace-like attributes for return
42 also pares down attributes to those we want
43 item is a dict
44
45 Note: should be removed when the set of keys expected by the api
46 and the set of keys returned by the image service are equivalent
47
48 """
49 # TODO(tr3buchet): this map is specific to s3 object store,
50 # replace with a list of keys for _filter_keys later
51 mapped_keys = {'status': 'imageState',
52 'id': 'imageId',
53 'name': 'imageLocation'}
54
55 mapped_item = {}
56 # TODO(tr3buchet):
57 # this chunk of code works with s3 and the local image service/glance
58 # when we switch to glance/local image service it can be replaced with
59 # a call to _filter_keys, and mapped_keys can be changed to a list
60 try:
61 for k, v in mapped_keys.iteritems():
62 # map s3 fields
63 mapped_item[k] = item[v]
64 except KeyError:
65 # return only the fields api expects
66 mapped_item = _filter_keys(item, mapped_keys.keys())
67
68 return mapped_item
69
70
71def _translate_status(item):
72 """
73 Translates status of image to match current Rackspace api bindings
74 item is a dict
75
76 Note: should be removed when the set of statuses expected by the api
77 and the set of statuses returned by the image service are equivalent
78
79 """
80 status_mapping = {
81 'pending': 'queued',
82 'decrypting': 'preparing',
83 'untarring': 'saving',
84 'available': 'active'}
85 try:
86 item['status'] = status_mapping[item['status']]
87 except KeyError:
88 # TODO(sirp): Performing translation of status (if necessary) here for
89 # now. Perhaps this should really be done in EC2 API and
90 # S3ImageService
91 pass
92
93
94def _filter_keys(item, keys):
95 """
96 Filters all model attributes except for keys
97 item is a dict
98
99 """
100 return dict((k, v) for k, v in item.iteritems() if k in keys)
101
102
103def _convert_image_id_to_hash(image):
104 if 'imageId' in image:
105 # Convert EC2-style ID (i-blah) to Rackspace-style (int)
106 image_id = abs(hash(image['imageId']))
107 image['imageId'] = image_id
108 image['id'] = image_id
109
110
111def _translate_s3_like_images(image_metadata):
112 """Work-around for leaky S3ImageService abstraction"""
113 api_metadata = image_metadata.copy()
114 _convert_image_id_to_hash(api_metadata)
115 api_metadata = _translate_keys(api_metadata)
116 _translate_status(api_metadata)
117 return api_metadata
118
119
120def _translate_from_image_service_to_api(image_metadata):
121 """Translate from ImageService to OpenStack API style attribute names
122
123 This involves 4 steps:
124
125 1. Filter out attributes that the OpenStack API doesn't need
126
127 2. Translate from base image attributes from names used by
128 BaseImageService to names used by OpenStack API
129
130 3. Add in any image properties
131
132 4. Format values according to API spec (for example dates must
133 look like "2010-08-10T12:00:00Z")
134 """
135 service_metadata = image_metadata.copy()
136 properties = service_metadata.pop('properties', {})
137
138 # 1. Filter out unecessary attributes
139 api_keys = ['id', 'name', 'updated_at', 'created_at', 'status']
140 api_metadata = utils.subset_dict(service_metadata, api_keys)
141
142 # 2. Translate base image attributes
143 api_map = {'updated_at': 'updated', 'created_at': 'created'}
144 api_metadata = utils.map_dict_keys(api_metadata, api_map)
145
146 # 3. Add in any image properties
147 # 3a. serverId is used for backups and snapshots
148 try:
149 api_metadata['serverId'] = int(properties['instance_id'])
150 except KeyError:
151 pass # skip if it's not present
152 except ValueError:
153 pass # skip if it's not an integer
154
155 # 3b. Progress special case
156 # TODO(sirp): ImageService doesn't have a notion of progress yet, so for
157 # now just fake it
158 if service_metadata['status'] == 'saving':
159 api_metadata['progress'] = 0
160
161 # 4. Format values
162 # 4a. Format Image Status (API requires uppercase)
163 api_metadata['status'] = _format_status_for_api(api_metadata['status'])
164
165 # 4b. Format timestamps
166 for attr in ('created', 'updated'):
167 if attr in api_metadata:
168 api_metadata[attr] = _format_datetime_for_api(
169 api_metadata[attr])
170
171 return api_metadata
172
173
174def _format_status_for_api(status):
175 """Return status in a format compliant with OpenStack API"""
176 mapping = {'queued': 'QUEUED',
177 'preparing': 'PREPARING',
178 'saving': 'SAVING',
179 'active': 'ACTIVE',
180 'killed': 'FAILED'}
181 return mapping[status]
182
183
184def _format_datetime_for_api(datetime_):
185 """Stringify datetime objects in a format compliant with OpenStack API"""
186 API_DATETIME_FMT = '%Y-%m-%dT%H:%M:%SZ'
187 return datetime_.strftime(API_DATETIME_FMT)
188
189
190def _safe_translate(image_metadata):
191 """Translate attributes for OpenStack API, temporary workaround for
192 S3ImageService attribute leakage.
193 """
194 # FIXME(sirp): The S3ImageService appears to be leaking implementation
195 # details, including its internal attribute names, and internal
196 # `status` values. Working around it for now.
197 s3_like_image = ('imageId' in image_metadata)
198 if s3_like_image:
199 translate = _translate_s3_like_images
200 else:
201 translate = _translate_from_image_service_to_api
202 return translate(image_metadata)
203
204
205class Controller(wsgi.Controller):35class Controller(wsgi.Controller):
36 """Base `wsgi.Controller` for retrieving/displaying images."""
20637
207 _serialization_metadata = {38 _serialization_metadata = {
208 'application/xml': {39 'application/xml': {
209 "attributes": {40 "attributes": {
210 "image": ["id", "name", "updated", "created", "status",41 "image": ["id", "name", "updated", "created", "status",
211 "serverId", "progress"]}}}42 "serverId", "progress"],
21243 "link": ["rel", "type", "href"],
213 def __init__(self):44 },
214 self._service = utils.import_object(FLAGS.image_service)45 },
46 }
47
48 def __init__(self, image_service=None, compute_service=None):
49 """Initialize new `ImageController`.
50
51 :param compute_service: `nova.compute.api:API`
52 :param image_service: `nova.image.service:BaseImageService`
53 """
54 _default_service = utils.import_object(flags.FLAGS.image_service)
55
56 self._compute_service = compute_service or compute.API()
57 self._image_service = image_service or _default_service
21558
216 def index(self, req):59 def index(self, req):
217 """Return all public images in brief"""60 """Return an index listing of images available to the request.
61
62 :param req: `wsgi.Request` object
63 """
218 context = req.environ['nova.context']64 context = req.environ['nova.context']
219 image_metas = self._service.index(context)65 images = self._image_service.index(context)
220 image_metas = common.limited(image_metas, req)66 images = common.limited(images, req)
221 return dict(images=image_metas)67 builder = self.get_builder(req).build
68 return dict(images=[builder(image, detail=False) for image in images])
22269
223 def detail(self, req):70 def detail(self, req):
224 """Return all public images in detail"""71 """Return a detailed index listing of images available to the request.
72
73 :param req: `wsgi.Request` object.
74 """
225 context = req.environ['nova.context']75 context = req.environ['nova.context']
226 image_metas = self._service.detail(context)76 images = self._image_service.detail(context)
227 image_metas = common.limited(image_metas, req)77 images = common.limited(images, req)
228 api_image_metas = [_safe_translate(image_meta)78 builder = self.get_builder(req).build
229 for image_meta in image_metas]79 return dict(images=[builder(image, detail=True) for image in images])
230 return dict(images=api_image_metas)
23180
232 def show(self, req, id):81 def show(self, req, id):
233 """Return data about the given image id"""82 """Return detailed information about a specific image.
83
84 :param req: `wsgi.Request` object
85 :param id: Image identifier (integer)
86 """
234 context = req.environ['nova.context']87 context = req.environ['nova.context']
235 try:88
236 image_id = common.get_image_id_from_image_hash(89 try:
237 self._service, context, id)90 image_id = int(id)
91 except ValueError:
92 explanation = _("Image not found.")
93 raise faults.Fault(webob.exc.HTTPNotFound(explanation=explanation))
94
95 try:
96 image = self._image_service.show(context, image_id)
238 except exception.NotFound:97 except exception.NotFound:
239 raise faults.Fault(exc.HTTPNotFound())98 explanation = _("Image '%d' not found.") % (image_id)
99 raise faults.Fault(webob.exc.HTTPNotFound(explanation=explanation))
240100
241 image_meta = self._service.show(context, image_id)101 return dict(image=self.get_builder(req).build(image, detail=True))
242 api_image_meta = _safe_translate(image_meta)
243 return dict(image=api_image_meta)
244102
245 def delete(self, req, id):103 def delete(self, req, id):
246 # Only public images are supported for now.104 """Delete an image, if allowed.
247 raise faults.Fault(exc.HTTPNotFound())105
106 :param req: `wsgi.Request` object
107 :param id: Image identifier (integer)
108 """
109 image_id = id
110 context = req.environ['nova.context']
111 self._image_service.delete(context, image_id)
112 return webob.exc.HTTPNoContent()
248113
249 def create(self, req):114 def create(self, req):
115 """Snapshot a server instance and save the image.
116
117 :param req: `wsgi.Request` object
118 """
250 context = req.environ['nova.context']119 context = req.environ['nova.context']
251 env = self._deserialize(req.body, req.get_content_type())120 content_type = req.get_content_type()
252 instance_id = env["image"]["serverId"]121 image = self._deserialize(req.body, content_type)
253 name = env["image"]["name"]122
254 image_meta = compute.API().snapshot(123 if not image:
255 context, instance_id, name)124 raise webob.exc.HTTPBadRequest()
256 api_image_meta = _safe_translate(image_meta)125
257 return dict(image=api_image_meta)126 try:
258127 server_id = image["image"]["serverId"]
259 def update(self, req, id):128 image_name = image["image"]["name"]
260 # Users may not modify public images, and that's all that129 except KeyError:
261 # we support for now.130 raise webob.exc.HTTPBadRequest()
262 raise faults.Fault(exc.HTTPNotFound())131
132 image = self._compute_service.snapshot(context, server_id, image_name)
133 return self.get_builder(req).build(image, detail=True)
134
135 def get_builder(self, request):
136 """Indicates that you must use a Controller subclass."""
137 raise NotImplementedError
138
139
140class ControllerV10(Controller):
141 """Version 1.0 specific controller logic."""
142
143 def get_builder(self, request):
144 """Property to get the ViewBuilder class we need to use."""
145 base_url = request.application_url
146 return images_view.ViewBuilderV10(base_url)
147
148
149class ControllerV11(Controller):
150 """Version 1.1 specific controller logic."""
151
152 def get_builder(self, request):
153 """Property to get the ViewBuilder class we need to use."""
154 base_url = request.application_url
155 return images_view.ViewBuilderV11(base_url)
263156
=== modified file 'nova/api/openstack/views/images.py'
--- nova/api/openstack/views/images.py 2011-03-17 07:41:01 +0000
+++ nova/api/openstack/views/images.py 2011-03-29 16:12:48 +0000
@@ -15,20 +15,100 @@
15# License for the specific language governing permissions and limitations15# License for the specific language governing permissions and limitations
16# under the License.16# under the License.
1717
18from nova.api.openstack import common18import os.path
1919
2020
21class ViewBuilder(object):21class ViewBuilder(object):
22 def __init__(self):22 """Base class for generating responses to OpenStack API image requests."""
23 pass23
24
25 def build(self, image_obj):
26 raise NotImplementedError()
27
28
29class ViewBuilderV11(ViewBuilder):
30 def __init__(self, base_url):24 def __init__(self, base_url):
31 self.base_url = base_url25 """Initialize new `ViewBuilder`."""
26 self._url = base_url
27
28 def _format_dates(self, image):
29 """Update all date fields to ensure standardized formatting."""
30 for attr in ['created_at', 'updated_at', 'deleted_at']:
31 if image.get(attr) is not None:
32 image[attr] = image[attr].strftime('%Y-%m-%dT%H:%M:%SZ')
33
34 def _format_status(self, image):
35 """Update the status field to standardize format."""
36 status_mapping = {
37 'pending': 'queued',
38 'decrypting': 'preparing',
39 'untarring': 'saving',
40 'available': 'active',
41 'killed': 'failed',
42 }
43
44 try:
45 image['status'] = status_mapping[image['status']].upper()
46 except KeyError:
47 image['status'] = image['status'].upper()
3248
33 def generate_href(self, image_id):49 def generate_href(self, image_id):
34 return "%s/images/%s" % (self.base_url, image_id)50 """Return an href string pointing to this object."""
51 return os.path.join(self._url, "images", str(image_id))
52
53 def build(self, image_obj, detail=False):
54 """Return a standardized image structure for display by the API."""
55 properties = image_obj.get("properties", {})
56
57 self._format_dates(image_obj)
58
59 if "status" in image_obj:
60 self._format_status(image_obj)
61
62 image = {
63 "id": image_obj["id"],
64 "name": image_obj["name"],
65 }
66
67 if "instance_id" in properties:
68 try:
69 image["serverId"] = int(properties["instance_id"])
70 except ValueError:
71 pass
72
73 if detail:
74 image.update({
75 "created": image_obj["created_at"],
76 "updated": image_obj["updated_at"],
77 "status": image_obj["status"],
78 })
79
80 if image["status"] == "SAVING":
81 image["progress"] = 0
82
83 return image
84
85
86class ViewBuilderV10(ViewBuilder):
87 """OpenStack API v1.0 Image Builder"""
88 pass
89
90
91class ViewBuilderV11(ViewBuilder):
92 """OpenStack API v1.1 Image Builder"""
93
94 def build(self, image_obj, detail=False):
95 """Return a standardized image structure for display by the API."""
96 image = ViewBuilder.build(self, image_obj, detail)
97 href = self.generate_href(image_obj["id"])
98
99 image["links"] = [{
100 "rel": "self",
101 "href": href,
102 },
103 {
104 "rel": "bookmark",
105 "type": "application/json",
106 "href": href,
107 },
108 {
109 "rel": "bookmark",
110 "type": "application/xml",
111 "href": href,
112 }]
113
114 return image
35115
=== modified file 'nova/tests/api/openstack/test_images.py'
--- nova/tests/api/openstack/test_images.py 2011-03-28 15:05:28 +0000
+++ nova/tests/api/openstack/test_images.py 2011-03-29 16:12:48 +0000
@@ -20,11 +20,13 @@
20and as a WSGI layer20and as a WSGI layer
21"""21"""
2222
23import copy
23import json24import json
24import datetime25import datetime
25import os26import os
26import shutil27import shutil
27import tempfile28import tempfile
29import xml.dom.minidom as minidom
2830
29import stubout31import stubout
30import webob32import webob
@@ -214,12 +216,14 @@
214216
215217
216class ImageControllerWithGlanceServiceTest(test.TestCase):218class ImageControllerWithGlanceServiceTest(test.TestCase):
217 """Test of the OpenStack API /images application controller"""219 """
218220 Test of the OpenStack API /images application controller w/Glance.
221 """
219 NOW_GLANCE_FORMAT = "2010-10-11T10:30:22"222 NOW_GLANCE_FORMAT = "2010-10-11T10:30:22"
220 NOW_API_FORMAT = "2010-10-11T10:30:22Z"223 NOW_API_FORMAT = "2010-10-11T10:30:22Z"
221224
222 def setUp(self):225 def setUp(self):
226 """Run before each test."""
223 super(ImageControllerWithGlanceServiceTest, self).setUp()227 super(ImageControllerWithGlanceServiceTest, self).setUp()
224 self.orig_image_service = FLAGS.image_service228 self.orig_image_service = FLAGS.image_service
225 FLAGS.image_service = 'nova.image.glance.GlanceImageService'229 FLAGS.image_service = 'nova.image.glance.GlanceImageService'
@@ -230,18 +234,30 @@
230 fakes.stub_out_rate_limiting(self.stubs)234 fakes.stub_out_rate_limiting(self.stubs)
231 fakes.stub_out_auth(self.stubs)235 fakes.stub_out_auth(self.stubs)
232 fakes.stub_out_key_pair_funcs(self.stubs)236 fakes.stub_out_key_pair_funcs(self.stubs)
233 fixtures = self._make_image_fixtures()237 self.fixtures = self._make_image_fixtures()
234 fakes.stub_out_glance(self.stubs, initial_fixtures=fixtures)238 fakes.stub_out_glance(self.stubs, initial_fixtures=self.fixtures)
235239
236 def tearDown(self):240 def tearDown(self):
241 """Run after each test."""
237 self.stubs.UnsetAll()242 self.stubs.UnsetAll()
238 FLAGS.image_service = self.orig_image_service243 FLAGS.image_service = self.orig_image_service
239 super(ImageControllerWithGlanceServiceTest, self).tearDown()244 super(ImageControllerWithGlanceServiceTest, self).tearDown()
240245
246 def _applicable_fixture(self, fixture, user_id):
247 """Determine if this fixture is applicable for given user id."""
248 is_public = fixture["is_public"]
249 try:
250 uid = int(fixture["properties"]["user_id"])
251 except KeyError:
252 uid = None
253 return uid == user_id or is_public
254
241 def test_get_image_index(self):255 def test_get_image_index(self):
242 req = webob.Request.blank('/v1.0/images')256 request = webob.Request.blank('/v1.0/images')
243 res = req.get_response(fakes.wsgi_app())257 response = request.get_response(fakes.wsgi_app())
244 image_metas = json.loads(res.body)['images']258
259 response_dict = json.loads(response.body)
260 response_list = response_dict["images"]
245261
246 expected = [{'id': 123, 'name': 'public image'},262 expected = [{'id': 123, 'name': 'public image'},
247 {'id': 124, 'name': 'queued backup'},263 {'id': 124, 'name': 'queued backup'},
@@ -249,32 +265,379 @@
249 {'id': 126, 'name': 'active backup'},265 {'id': 126, 'name': 'active backup'},
250 {'id': 127, 'name': 'killed backup'}]266 {'id': 127, 'name': 'killed backup'}]
251267
252 self.assertDictListMatch(image_metas, expected)268 self.assertDictListMatch(response_list, expected)
269
270 def test_get_image(self):
271 request = webob.Request.blank('/v1.0/images/123')
272 response = request.get_response(fakes.wsgi_app())
273
274 self.assertEqual(200, response.status_int)
275
276 actual_image = json.loads(response.body)
277
278 expected_image = {
279 "image": {
280 "id": 123,
281 "name": "public image",
282 "updated": self.NOW_API_FORMAT,
283 "created": self.NOW_API_FORMAT,
284 "status": "ACTIVE",
285 },
286 }
287
288 self.assertEqual(expected_image, actual_image)
289
290 def test_get_image_v1_1(self):
291 request = webob.Request.blank('/v1.1/images/123')
292 response = request.get_response(fakes.wsgi_app())
293
294 actual_image = json.loads(response.body)
295
296 href = "http://localhost/v1.1/images/123"
297
298 expected_image = {
299 "image": {
300 "id": 123,
301 "name": "public image",
302 "updated": self.NOW_API_FORMAT,
303 "created": self.NOW_API_FORMAT,
304 "status": "ACTIVE",
305 "links": [{
306 "rel": "self",
307 "href": href,
308 },
309 {
310 "rel": "bookmark",
311 "type": "application/json",
312 "href": href,
313 },
314 {
315 "rel": "bookmark",
316 "type": "application/xml",
317 "href": href,
318 }],
319 },
320 }
321
322 self.assertEqual(expected_image, actual_image)
323
324 def test_get_image_xml(self):
325 request = webob.Request.blank('/v1.0/images/123')
326 request.accept = "application/xml"
327 response = request.get_response(fakes.wsgi_app())
328
329 actual_image = minidom.parseString(response.body.replace(" ", ""))
330
331 expected_now = self.NOW_API_FORMAT
332 expected_image = minidom.parseString("""
333 <image id="123"
334 name="public image"
335 updated="%(expected_now)s"
336 created="%(expected_now)s"
337 status="ACTIVE" />
338 """ % (locals()))
339
340 self.assertEqual(expected_image.toxml(), actual_image.toxml())
341
342 def test_get_image_v1_1_xml(self):
343 request = webob.Request.blank('/v1.1/images/123')
344 request.accept = "application/xml"
345 response = request.get_response(fakes.wsgi_app())
346
347 actual_image = minidom.parseString(response.body.replace(" ", ""))
348
349 expected_href = "http://localhost/v1.1/images/123"
350 expected_now = self.NOW_API_FORMAT
351 expected_image = minidom.parseString("""
352 <image id="123"
353 name="public image"
354 updated="%(expected_now)s"
355 created="%(expected_now)s"
356 status="ACTIVE">
357 <links>
358 <link href="%(expected_href)s" rel="self"/>
359 <link href="%(expected_href)s" rel="bookmark"
360 type="application/json" />
361 <link href="%(expected_href)s" rel="bookmark"
362 type="application/xml" />
363 </links>
364 </image>
365 """.replace(" ", "") % (locals()))
366
367 self.assertEqual(expected_image.toxml(), actual_image.toxml())
368
369 def test_get_image_404_json(self):
370 request = webob.Request.blank('/v1.0/images/NonExistantImage')
371 response = request.get_response(fakes.wsgi_app())
372 self.assertEqual(404, response.status_int)
373
374 expected = {
375 "itemNotFound": {
376 "message": "Image not found.",
377 "code": 404,
378 },
379 }
380
381 actual = json.loads(response.body)
382
383 self.assertEqual(expected, actual)
384
385 def test_get_image_404_xml(self):
386 request = webob.Request.blank('/v1.0/images/NonExistantImage')
387 request.accept = "application/xml"
388 response = request.get_response(fakes.wsgi_app())
389 self.assertEqual(404, response.status_int)
390
391 expected = minidom.parseString("""
392 <itemNotFound code="404">
393 <message>
394 Image not found.
395 </message>
396 </itemNotFound>
397 """.replace(" ", ""))
398
399 actual = minidom.parseString(response.body.replace(" ", ""))
400
401 self.assertEqual(expected.toxml(), actual.toxml())
402
403 def test_get_image_404_v1_1_json(self):
404 request = webob.Request.blank('/v1.1/images/NonExistantImage')
405 response = request.get_response(fakes.wsgi_app())
406 self.assertEqual(404, response.status_int)
407
408 expected = {
409 "itemNotFound": {
410 "message": "Image not found.",
411 "code": 404,
412 },
413 }
414
415 actual = json.loads(response.body)
416
417 self.assertEqual(expected, actual)
418
419 def test_get_image_404_v1_1_xml(self):
420 request = webob.Request.blank('/v1.1/images/NonExistantImage')
421 request.accept = "application/xml"
422 response = request.get_response(fakes.wsgi_app())
423 self.assertEqual(404, response.status_int)
424
425 expected = minidom.parseString("""
426 <itemNotFound code="404">
427 <message>
428 Image not found.
429 </message>
430 </itemNotFound>
431 """.replace(" ", ""))
432
433 actual = minidom.parseString(response.body.replace(" ", ""))
434
435 self.assertEqual(expected.toxml(), actual.toxml())
436
437 def test_get_image_index_v1_1(self):
438 request = webob.Request.blank('/v1.1/images')
439 response = request.get_response(fakes.wsgi_app())
440
441 response_dict = json.loads(response.body)
442 response_list = response_dict["images"]
443
444 fixtures = copy.copy(self.fixtures)
445
446 for image in fixtures:
447 if not self._applicable_fixture(image, 1):
448 fixtures.remove(image)
449 continue
450
451 href = "http://localhost/v1.1/images/%s" % image["id"]
452 test_image = {
453 "id": image["id"],
454 "name": image["name"],
455 "links": [{
456 "rel": "self",
457 "href": "http://localhost/v1.1/images/%s" % image["id"],
458 },
459 {
460 "rel": "bookmark",
461 "type": "application/json",
462 "href": href,
463 },
464 {
465 "rel": "bookmark",
466 "type": "application/xml",
467 "href": href,
468 }],
469 }
470 self.assertTrue(test_image in response_list)
471
472 self.assertEqual(len(response_list), len(fixtures))
253473
254 def test_get_image_details(self):474 def test_get_image_details(self):
255 req = webob.Request.blank('/v1.0/images/detail')475 request = webob.Request.blank('/v1.0/images/detail')
256 res = req.get_response(fakes.wsgi_app())476 response = request.get_response(fakes.wsgi_app())
257 image_metas = json.loads(res.body)['images']477
258478 response_dict = json.loads(response.body)
259 now = self.NOW_API_FORMAT479 response_list = response_dict["images"]
260 expected = [480
261 {'id': 123, 'name': 'public image', 'updated': now,481 expected = [{
262 'created': now, 'status': 'ACTIVE'},482 'id': 123,
263 {'id': 124, 'name': 'queued backup', 'serverId': 42,483 'name': 'public image',
264 'updated': now, 'created': now,484 'updated': self.NOW_API_FORMAT,
265 'status': 'QUEUED'},485 'created': self.NOW_API_FORMAT,
266 {'id': 125, 'name': 'saving backup', 'serverId': 42,486 'status': 'ACTIVE',
267 'updated': now, 'created': now,487 },
268 'status': 'SAVING', 'progress': 0},488 {
269 {'id': 126, 'name': 'active backup', 'serverId': 42,489 'id': 124,
270 'updated': now, 'created': now,490 'name': 'queued backup',
271 'status': 'ACTIVE'},491 'serverId': 42,
272 {'id': 127, 'name': 'killed backup', 'serverId': 42,492 'updated': self.NOW_API_FORMAT,
273 'updated': now, 'created': now,493 'created': self.NOW_API_FORMAT,
274 'status': 'FAILED'}494 'status': 'QUEUED',
275 ]495 },
276496 {
277 self.assertDictListMatch(image_metas, expected)497 'id': 125,
498 'name': 'saving backup',
499 'serverId': 42,
500 'updated': self.NOW_API_FORMAT,
501 'created': self.NOW_API_FORMAT,
502 'status': 'SAVING',
503 'progress': 0,
504 },
505 {
506 'id': 126,
507 'name': 'active backup',
508 'serverId': 42,
509 'updated': self.NOW_API_FORMAT,
510 'created': self.NOW_API_FORMAT,
511 'status': 'ACTIVE'
512 },
513 {
514 'id': 127,
515 'name': 'killed backup', 'serverId': 42,
516 'updated': self.NOW_API_FORMAT,
517 'created': self.NOW_API_FORMAT,
518 'status': 'FAILED',
519 }]
520
521 self.assertDictListMatch(expected, response_list)
522
523 def test_get_image_details_v1_1(self):
524 request = webob.Request.blank('/v1.1/images/detail')
525 response = request.get_response(fakes.wsgi_app())
526
527 response_dict = json.loads(response.body)
528 response_list = response_dict["images"]
529
530 expected = [{
531 'id': 123,
532 'name': 'public image',
533 'updated': self.NOW_API_FORMAT,
534 'created': self.NOW_API_FORMAT,
535 'status': 'ACTIVE',
536 "links": [{
537 "rel": "self",
538 "href": "http://localhost/v1.1/images/123",
539 },
540 {
541 "rel": "bookmark",
542 "type": "application/json",
543 "href": "http://localhost/v1.1/images/123",
544 },
545 {
546 "rel": "bookmark",
547 "type": "application/xml",
548 "href": "http://localhost/v1.1/images/123",
549 }],
550 },
551 {
552 'id': 124,
553 'name': 'queued backup',
554 'serverId': 42,
555 'updated': self.NOW_API_FORMAT,
556 'created': self.NOW_API_FORMAT,
557 'status': 'QUEUED',
558 "links": [{
559 "rel": "self",
560 "href": "http://localhost/v1.1/images/124",
561 },
562 {
563 "rel": "bookmark",
564 "type": "application/json",
565 "href": "http://localhost/v1.1/images/124",
566 },
567 {
568 "rel": "bookmark",
569 "type": "application/xml",
570 "href": "http://localhost/v1.1/images/124",
571 }],
572 },
573 {
574 'id': 125,
575 'name': 'saving backup',
576 'serverId': 42,
577 'updated': self.NOW_API_FORMAT,
578 'created': self.NOW_API_FORMAT,
579 'status': 'SAVING',
580 'progress': 0,
581 "links": [{
582 "rel": "self",
583 "href": "http://localhost/v1.1/images/125",
584 },
585 {
586 "rel": "bookmark",
587 "type": "application/json",
588 "href": "http://localhost/v1.1/images/125",
589 },
590 {
591 "rel": "bookmark",
592 "type": "application/xml",
593 "href": "http://localhost/v1.1/images/125",
594 }],
595 },
596 {
597 'id': 126,
598 'name': 'active backup',
599 'serverId': 42,
600 'updated': self.NOW_API_FORMAT,
601 'created': self.NOW_API_FORMAT,
602 'status': 'ACTIVE',
603 "links": [{
604 "rel": "self",
605 "href": "http://localhost/v1.1/images/126",
606 },
607 {
608 "rel": "bookmark",
609 "type": "application/json",
610 "href": "http://localhost/v1.1/images/126",
611 },
612 {
613 "rel": "bookmark",
614 "type": "application/xml",
615 "href": "http://localhost/v1.1/images/126",
616 }],
617 },
618 {
619 'id': 127,
620 'name': 'killed backup', 'serverId': 42,
621 'updated': self.NOW_API_FORMAT,
622 'created': self.NOW_API_FORMAT,
623 'status': 'FAILED',
624 "links": [{
625 "rel": "self",
626 "href": "http://localhost/v1.1/images/127",
627 },
628 {
629 "rel": "bookmark",
630 "type": "application/json",
631 "href": "http://localhost/v1.1/images/127",
632 },
633 {
634 "rel": "bookmark",
635 "type": "application/xml",
636 "href": "http://localhost/v1.1/images/127",
637 }],
638 }]
639
640 self.assertDictListMatch(expected, response_list)
278641
279 def test_get_image_found(self):642 def test_get_image_found(self):
280 req = webob.Request.blank('/v1.0/images/123')643 req = webob.Request.blank('/v1.0/images/123')