Merge lp:~justin-fathomdb/nova/ec2-filters into lp:~hudson-openstack/nova/trunk

Proposed by justinsb
Status: Work in progress
Proposed branch: lp:~justin-fathomdb/nova/ec2-filters
Merge into: lp:~hudson-openstack/nova/trunk
Diff against target: 417 lines (+270/-10)
7 files modified
nova/api/ec2/apirequest.py (+7/-4)
nova/api/ec2/cloud.py (+27/-2)
nova/api/predicates.py (+133/-0)
nova/db/api.py (+5/-0)
nova/db/sqlalchemy/api.py (+49/-0)
nova/tests/test_cloud.py (+39/-0)
nova/volume/api.py (+10/-4)
To merge this branch: bzr merge lp:~justin-fathomdb/nova/ec2-filters
Reviewer Review Type Date Requested Status
Nova Core security contacts Pending
Devin Carlen Pending
Review via email: mp+53266@code.launchpad.net

This proposal supersedes a proposal from 2011-03-11.

Description of the change

Initial support for EC2 filters

Targeted for diablo, but I can't get LP to understand that!

To post a comment you must log in.
Revision history for this message
justinsb (justin-fathomdb) wrote : Posted in a previous version of this proposal

I could use some pointers on how to unit-test the EC2 API, so I can check this still works when I'm not cheating and going direct to the CloudController...

Revision history for this message
Devin Carlen (devcamcar) wrote : Posted in a previous version of this proposal

Hi Justin,

Nice work here but I think it's a bit overkill for the bug. Excerpt from bug 732924:

"This makes it difficult to figure out which project a particular volume is associated with. Instances, on the other hand specifically return a project as the owner (in a separate attribute at that):

[ownerId] => testproject

Similarly, addresses also report the project as an owner:

[instanceId] => i-00000002 (testproject)"

The bug isn't that we need any advanced filtering. This only affects EC2 API and how we have to work around a few of its sharp edges.

And it looks like Ryan Lane already made this fix:

https://code.launchpad.net/~rlane/nova/lp732924/+merge/53049

So what is the need for this merge? It doesn't seem to have anything to do with the bug you linked.

review: Needs Information
Revision history for this message
justinsb (justin-fathomdb) wrote : Posted in a previous version of this proposal

Ryan raised his issue on the mailing list and I said that this was one of the three solutions I saw, and that I was working on it. The other options were to put the projectId into the metadata, or to change the status field. To be honest, I didn't think we'd be willing to change the status field, because we've just broken anyone that was previously using that to get the user.

The fact that an alternative solution was merged does rather reduce the need for the patch, but this is definitely a solution that Ryan and I discussed to his issue, which is why I linked the bug.

This patch is still the "right thing to do", and we'll have to do it for true EC2 API compatibility, which I thought was already an approved goal. We'll probably also have to implement filtering for the OpenStack API once users have a sufficient number of VMs (which I presume is why AWS implemented it), which is why the patch is architected with a separation of the AWS filterspec from an underlying predicate. Finally, it would dramatically simplify and harmonize a lot of our code, by using functions that take predicates rather than a long list of similar functions varying only in their arguments.

But maybe not in Cactus? If we're locking down the cactus branch for release, should I propose a merge into the Diablo branch?

Revision history for this message
Thierry Carrez (ttx) wrote : Posted in a previous version of this proposal

The referenced bug is already fixed. That sounds like a new feature we could easily rubberstamp for Diablo.

Revision history for this message
Devin Carlen (devcamcar) wrote : Posted in a previous version of this proposal

Agreed; let's bring this in for Diablo.

Revision history for this message
Devin Carlen (devcamcar) wrote : Posted in a previous version of this proposal

Marked as rejected for now. We'll re-review in Diablo.

Revision history for this message
justinsb (justin-fathomdb) wrote : Posted in a previous version of this proposal

How do I propose for merge into Diablo, so that the code doesn't get lost?

Is the release process (in terms of version control branches) documented
anywhere? I'm finding it all a bit "unusual"

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

You should keep the branch merge proposal "work in progress" until Diablo branches open. You'll probably have to merge with trunk before reproposing for merging.

I'd suggest filing a blueprint for a review (rubberstamp) session at the summit, linking to the branch.

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

That seems a highly unusual "process". Is this process documented and
agreed? (And can you send me a link please!)

I think perhaps at Diablo we should have a discussion about which of
the successful open source release/development models we should use.

Revision history for this message
Devin Carlen (devcamcar) wrote :

Maybe its a bit unusual but there's no diablo branch right now to propose merging into. Just means you'll have to babysit the branch for a little while. :)

Revision history for this message
Thierry Carrez (ttx) wrote : Posted in a previous version of this proposal

Keep your branch until the Diablo merge window opens. You don't need to propose for merging. The code won't get lost. The branch will stay.

You can also set up your own "development branch" where you merge all those "feature branches" for more advanced testing.

Unmerged revisions

789. By justinsb

Added unit test, fixed a few bugs

788. By justinsb

Initial implementation of predicates, exposed as filters in the EC2 API

787. By justinsb

Cope with keys with more than one dot; don't just ignore the extra bits!

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'nova/api/ec2/apirequest.py'
2--- nova/api/ec2/apirequest.py 2011-03-04 19:01:25 +0000
3+++ nova/api/ec2/apirequest.py 2011-03-14 15:58:21 +0000
4@@ -133,11 +133,14 @@
5 # NOTE(vish): Automatically convert strings back
6 # into their respective values
7 value = _try_convert(value)
8+
9 if len(parts) > 1:
10- d = args.get(key, {})
11- d[parts[1]] = value
12- value = d
13- args[key] = value
14+ parent = args.setdefault(key, {})
15+ for i in xrange(1, len(parts) - 1):
16+ parent = parent.setdefault(parts[i], {})
17+ parent[parts[-1]] = value
18+ else:
19+ args[key] = value
20
21 for key in args.keys():
22 # NOTE(vish): Turn numeric dict keys into lists
23
24=== modified file 'nova/api/ec2/cloud.py'
25--- nova/api/ec2/cloud.py 2011-03-03 23:05:00 +0000
26+++ nova/api/ec2/cloud.py 2011-03-14 15:58:21 +0000
27@@ -2,6 +2,7 @@
28
29 # Copyright 2010 United States Government as represented by the
30 # Administrator of the National Aeronautics and Space Administration.
31+# Copyright 2011 Justin Santa Barbara
32 # All Rights Reserved.
33 #
34 # Licensed under the Apache License, Version 2.0 (the "License"); you may
35@@ -39,6 +40,7 @@
36 from nova import network
37 from nova import utils
38 from nova import volume
39+from nova.api import predicates
40 from nova.api.ec2 import ec2utils
41 from nova.compute import instance_types
42 from nova.image import s3
43@@ -533,15 +535,38 @@
44 return self.compute_api.get_ajax_console(context,
45 instance_id=instance_id)
46
47- def describe_volumes(self, context, volume_id=None, **kwargs):
48+ def _map_ec2_filter_to_predicate(self, ec2_filters):
49+ if ec2_filters is None:
50+ return predicates.TruePredicate()
51+ value_predicates = []
52+ for ec2_filter in ec2_filters:
53+ name = ec2_filter['name']
54+ acceptable_values = []
55+ for v in ec2_filter['value'].values():
56+ acceptable_values.append(v)
57+ if not acceptable_values:
58+ return predicates.FalsePredicate()
59+ value_predicates.append(predicates.ValuePredicate(
60+ name,
61+ acceptable_values))
62+ if not value_predicates:
63+ return predicates.TruePredicate()
64+
65+ return predicates.CompoundAndPredicate(value_predicates)
66+
67+ def describe_volumes(self, context, volume_id=None, filter=None,
68+ **kwargs):
69+ predicate = self._map_ec2_filter_to_predicate(filter)
70 if volume_id:
71+ #TODO(justinsb): We probably should support filters here
72+ # but filters with specified ids are a very odd request
73 volumes = []
74 for ec2_id in volume_id:
75 internal_id = ec2utils.ec2_id_to_id(ec2_id)
76 volume = self.volume_api.get(context, internal_id)
77 volumes.append(volume)
78 else:
79- volumes = self.volume_api.get_all(context)
80+ volumes = self.volume_api.get_all(context, predicate)
81 volumes = [self._format_volume(context, v) for v in volumes]
82 return {'volumeSet': volumes}
83
84
85=== added file 'nova/api/predicates.py'
86--- nova/api/predicates.py 1970-01-01 00:00:00 +0000
87+++ nova/api/predicates.py 2011-03-14 15:58:21 +0000
88@@ -0,0 +1,133 @@
89+# vim: tabstop=4 shiftwidth=4 softtabstop=4
90+
91+# Copyright 2011 Justin Santa Barbara
92+# All Rights Reserved.
93+#
94+# Licensed under the Apache License, Version 2.0 (the "License"); you may
95+# not use this file except in compliance with the License. You may obtain
96+# a copy of the License at
97+#
98+# http://www.apache.org/licenses/LICENSE-2.0
99+#
100+# Unless required by applicable law or agreed to in writing, software
101+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
102+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
103+# License for the specific language governing permissions and limitations
104+# under the License.
105+
106+"""Model classes that represent predicates"""
107+
108+from nova import log as logging
109+
110+
111+LOG = logging.getLogger('nova.api.predicates')
112+
113+# Well known properties
114+PROPERTY_PROJECT_ID = 'project_id'
115+
116+
117+class Predicate(object):
118+ """A predicate is a logical function which is either true or false.
119+
120+ We use it for filtering values"""
121+ def __init__(self):
122+ pass
123+
124+ def join_and(self, rhs):
125+ """Returns a predicate which is true iff both this and rhs are true"""
126+ return CompoundAndPredicate([self, rhs])
127+
128+ def filter_by_project(self, project_id):
129+ """Adds a predicate so we only match the specified project"""
130+ project_filter = ValuePredicate(PROPERTY_PROJECT_ID, project_id)
131+ return self.join_and(project_filter)
132+
133+ def collapse_to_filters(self):
134+ """Collapse to a dictionary of key => value_list filters.
135+
136+ If the predicate is unsatisfiable, return False.
137+ If the predicate cannot be simplified in this way, raise an exception.
138+ The predicate should be true for an object o iff
139+ for each k,v in map: o[k] in v
140+ """
141+ raise NotImplementedError()
142+
143+
144+class ValuePredicate(Predicate):
145+ """A predicate on a single property
146+
147+ The predicate is satisfied if the value is a member of acceptable_values
148+ """
149+ def __init__(self, key, acceptable_values):
150+ super(ValuePredicate, self).__init__()
151+ self.key = key
152+ if not isinstance(acceptable_values, list):
153+ raise Exception("acceptable_values must be a list")
154+ self.acceptable_values = acceptable_values
155+
156+ def collapse_to_filters(self):
157+ collapsed = {}
158+ collapsed[self.key] = self.acceptable_values
159+ return collapsed
160+
161+
162+def _intersect(lhs, rhs):
163+ """Returns the list of items in both lhs and rhs"""
164+ return list(set(lhs) & set(rhs))
165+
166+
167+class CompoundAndPredicate(Predicate):
168+ """A predicate where all children must be satisfied"""
169+ def __init__(self, children):
170+ super(CompoundAndPredicate, self).__init__()
171+ self.children = children
172+
173+ def join_and(self, rhs):
174+ #NOTE(justinsb): We could do some immediate-simplification here
175+ # e.g. if rhs is a CompoundAndPredicate
176+ conjunction = self.children + rhs
177+ return CompoundAndPredicate(conjunction)
178+
179+ def collapse_to_filters(self):
180+ collapsed = {}
181+ for child in self.children:
182+ child_map = child.collapse_to_filters()
183+ if child_map == False:
184+ return False
185+
186+ for k, v in child_map.iteritems():
187+ values = collapsed.get(k)
188+ if values is None:
189+ values = v
190+ else:
191+ values = _intersect(values, v)
192+ if not values:
193+ # Unsatisfiable (something like X=A and X=B, A != B)
194+ return False
195+
196+ collapsed[k] = values
197+
198+ return collapsed
199+
200+
201+class FalsePredicate(Predicate):
202+ """A predicate which is always False"""
203+
204+ def join_and(self, rhs):
205+ # We are never true, so our conjunction can never be true
206+ return FalsePredicate()
207+
208+ def collapse_to_filters(self):
209+ # Unsatisfiable, return False
210+ return False
211+
212+
213+class TruePredicate(Predicate):
214+ """A predicate which is always True"""
215+
216+ def join_and(self, rhs):
217+ return rhs
218+
219+ def collapse_to_filters(self):
220+ # No criteria
221+ return {}
222
223=== modified file 'nova/db/api.py'
224--- nova/db/api.py 2011-03-09 21:27:38 +0000
225+++ nova/db/api.py 2011-03-14 15:58:21 +0000
226@@ -770,6 +770,11 @@
227 return IMPL.volume_get_all_by_project(context, project_id)
228
229
230+def volume_get_predicated(context, predicate):
231+ """Get a list of volumes matching the specified predicate."""
232+ return IMPL.volume_get_predicated(context, predicate)
233+
234+
235 def volume_get_by_ec2_id(context, ec2_id):
236 """Get a volume by ec2 id."""
237 return IMPL.volume_get_by_ec2_id(context, ec2_id)
238
239=== modified file 'nova/db/sqlalchemy/api.py'
240--- nova/db/sqlalchemy/api.py 2011-03-11 23:05:02 +0000
241+++ nova/db/sqlalchemy/api.py 2011-03-14 15:58:21 +0000
242@@ -2,6 +2,7 @@
243
244 # Copyright 2010 United States Government as represented by the
245 # Administrator of the National Aeronautics and Space Administration.
246+# Copyright 2011 Justin Santa Barbara
247 # All Rights Reserved.
248 #
249 # Licensed under the Apache License, Version 2.0 (the "License"); you may
250@@ -25,7 +26,9 @@
251 from nova import db
252 from nova import exception
253 from nova import flags
254+from nova import log as logging
255 from nova import utils
256+from nova.api import predicates
257 from nova.db.sqlalchemy import models
258 from nova.db.sqlalchemy.session import get_session
259 from sqlalchemy import or_
260@@ -36,8 +39,11 @@
261 from sqlalchemy.sql import func
262 from sqlalchemy.sql.expression import literal_column
263
264+
265 FLAGS = flags.FLAGS
266
267+LOG = logging.getLogger('nova.db.sqlalchemy')
268+
269
270 def is_admin_context(context):
271 """Indicates if the request context is an administrator."""
272@@ -1511,6 +1517,49 @@
273 return result
274
275
276+@require_context
277+def volume_get_predicated(context, predicate=None):
278+ session = get_session()
279+ query = session.query(models.Volume).\
280+ options(joinedload('instance')).\
281+ filter_by(deleted=can_read_deleted(context))
282+
283+ if predicate:
284+ filters = predicate.collapse_to_filters()
285+ LOG.debug("filters=%s", filters)
286+ else:
287+ filters = {}
288+
289+ # If the predicate is known-unsatisfiable, don't bother hitting the DB
290+ if filters == False:
291+ return []
292+
293+ filtered_project_id = None
294+
295+ # For now, we whitelist/hard-code the filters...
296+ for k, v in filters.iteritems():
297+ if k == predicates.PROPERTY_PROJECT_ID:
298+ for project_id in v:
299+ authorize_project_context(context, project_id)
300+
301+ #TODO(justinsb): Does SA simplify a single-item IN clause?
302+ if len(v) == 1:
303+ query = query.filter(models.Volume.project_id == v[0])
304+ else:
305+ query = query.filter(models.Volume.project_id.in_(v))
306+
307+ filtered_project_id = v
308+ else:
309+ raise exception.ApiError(_("Unknown predicate: %s") % k)
310+
311+ # Only an admin can do a query without a project filter
312+ if not filtered_project_id:
313+ if not is_admin_context(context):
314+ raise exception.NotAuthorized()
315+
316+ return query.all()
317+
318+
319 @require_admin_context
320 def volume_get_all(context):
321 session = get_session()
322
323=== modified file 'nova/tests/test_cloud.py'
324--- nova/tests/test_cloud.py 2011-03-10 04:42:11 +0000
325+++ nova/tests/test_cloud.py 2011-03-14 15:58:21 +0000
326@@ -30,6 +30,7 @@
327 from nova import context
328 from nova import crypto
329 from nova import db
330+from nova import exception
331 from nova import flags
332 from nova import log as logging
333 from nova import rpc
334@@ -176,6 +177,44 @@
335 db.volume_destroy(self.context, vol1['id'])
336 db.volume_destroy(self.context, vol2['id'])
337
338+ def test_describe_volumes_filters(self):
339+ """Makes sure describe_volumes works and filters results."""
340+ vol1 = db.volume_create(self.context, {'project_id': 'proj1'})
341+ vol2 = db.volume_create(self.context, {'project_id': 'proj2'})
342+
343+ filtr = [{ 'name': 'project_id', 'value': {'1': 'proj1'} }]
344+ result = self.cloud.describe_volumes(self.context, filter=filtr)
345+ self.assertEqual(len(result['volumeSet']), 1)
346+
347+ filtr = [{ 'name': 'project_id', 'value': {'1': 'proj2'} }]
348+ result = self.cloud.describe_volumes(self.context, filter=filtr)
349+ self.assertEqual(len(result['volumeSet']), 1)
350+
351+ filtr = [{ 'name': 'project_id', 'value': {'1': 'proj3'} }]
352+ result = self.cloud.describe_volumes(self.context, filter=filtr)
353+ self.assertEqual(len(result['volumeSet']), 0)
354+
355+ filtr = [{ 'name': 'project_id', 'value': {'1': 'proj1',
356+ '2': 'proj2'} }]
357+ result = self.cloud.describe_volumes(self.context, filter=filtr)
358+ self.assertEqual(len(result['volumeSet']), 2)
359+
360+ # Unsatisfiable (shouldn't even hit DB!)
361+ filtr = [{ 'name': 'project_id', 'value': {'1': 'proj1'} },
362+ { 'name': 'project_id', 'value': {'1': 'proj2'} },
363+ ]
364+ result = self.cloud.describe_volumes(self.context, filter=filtr)
365+ self.assertEqual(len(result['volumeSet']), 0)
366+
367+ # Not a supported value
368+ filtr = [{ 'name': 'notso_key', 'value': {'1': 'notso_value'} }]
369+ self.assertRaises(exception.ApiError,
370+ self.cloud.describe_volumes,
371+ self.context, filter=filtr)
372+
373+ db.volume_destroy(self.context, vol1['id'])
374+ db.volume_destroy(self.context, vol2['id'])
375+
376 def test_describe_availability_zones(self):
377 """Makes sure describe_availability_zones works and filters results."""
378 service1 = db.service_create(self.context, {'host': 'host1_zones',
379
380=== modified file 'nova/volume/api.py'
381--- nova/volume/api.py 2011-02-19 07:15:42 +0000
382+++ nova/volume/api.py 2011-03-14 15:58:21 +0000
383@@ -2,6 +2,7 @@
384
385 # Copyright 2010 United States Government as represented by the
386 # Administrator of the National Aeronautics and Space Administration.
387+# Copyright 2011 Justin Santa Barbara
388 # All Rights Reserved.
389 #
390 # Licensed under the Apache License, Version 2.0 (the "License"); you may
391@@ -28,6 +29,7 @@
392 from nova import log as logging
393 from nova import quota
394 from nova import rpc
395+from nova.api import predicates
396 from nova.db import base
397
398 FLAGS = flags.FLAGS
399@@ -84,10 +86,14 @@
400 def get(self, context, volume_id):
401 return self.db.volume_get(context, volume_id)
402
403- def get_all(self, context):
404- if context.is_admin:
405- return self.db.volume_get_all(context)
406- return self.db.volume_get_all_by_project(context, context.project_id)
407+ def get_all(self, context, predicate=None):
408+ if predicate is None:
409+ predicate = predicates.TruePredicate()
410+
411+ if not context.is_admin:
412+ predicate = predicate.filter_by_project(context.project_id)
413+
414+ return self.db.volume_get_predicated(context, predicate)
415
416 def check_attach(self, context, volume_id):
417 volume = self.get(context, volume_id)