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
=== modified file 'nova/api/ec2/apirequest.py'
--- nova/api/ec2/apirequest.py 2011-03-04 19:01:25 +0000
+++ nova/api/ec2/apirequest.py 2011-03-14 15:58:21 +0000
@@ -133,11 +133,14 @@
133 # NOTE(vish): Automatically convert strings back133 # NOTE(vish): Automatically convert strings back
134 # into their respective values134 # into their respective values
135 value = _try_convert(value)135 value = _try_convert(value)
136
136 if len(parts) > 1:137 if len(parts) > 1:
137 d = args.get(key, {})138 parent = args.setdefault(key, {})
138 d[parts[1]] = value139 for i in xrange(1, len(parts) - 1):
139 value = d140 parent = parent.setdefault(parts[i], {})
140 args[key] = value141 parent[parts[-1]] = value
142 else:
143 args[key] = value
141144
142 for key in args.keys():145 for key in args.keys():
143 # NOTE(vish): Turn numeric dict keys into lists146 # NOTE(vish): Turn numeric dict keys into lists
144147
=== modified file 'nova/api/ec2/cloud.py'
--- nova/api/ec2/cloud.py 2011-03-03 23:05:00 +0000
+++ nova/api/ec2/cloud.py 2011-03-14 15:58:21 +0000
@@ -2,6 +2,7 @@
22
3# Copyright 2010 United States Government as represented by the3# Copyright 2010 United States Government as represented by the
4# Administrator of the National Aeronautics and Space Administration.4# Administrator of the National Aeronautics and Space Administration.
5# Copyright 2011 Justin Santa Barbara
5# All Rights Reserved.6# All Rights Reserved.
6#7#
7# Licensed under the Apache License, Version 2.0 (the "License"); you may8# Licensed under the Apache License, Version 2.0 (the "License"); you may
@@ -39,6 +40,7 @@
39from nova import network40from nova import network
40from nova import utils41from nova import utils
41from nova import volume42from nova import volume
43from nova.api import predicates
42from nova.api.ec2 import ec2utils44from nova.api.ec2 import ec2utils
43from nova.compute import instance_types45from nova.compute import instance_types
44from nova.image import s346from nova.image import s3
@@ -533,15 +535,38 @@
533 return self.compute_api.get_ajax_console(context,535 return self.compute_api.get_ajax_console(context,
534 instance_id=instance_id)536 instance_id=instance_id)
535537
536 def describe_volumes(self, context, volume_id=None, **kwargs):538 def _map_ec2_filter_to_predicate(self, ec2_filters):
539 if ec2_filters is None:
540 return predicates.TruePredicate()
541 value_predicates = []
542 for ec2_filter in ec2_filters:
543 name = ec2_filter['name']
544 acceptable_values = []
545 for v in ec2_filter['value'].values():
546 acceptable_values.append(v)
547 if not acceptable_values:
548 return predicates.FalsePredicate()
549 value_predicates.append(predicates.ValuePredicate(
550 name,
551 acceptable_values))
552 if not value_predicates:
553 return predicates.TruePredicate()
554
555 return predicates.CompoundAndPredicate(value_predicates)
556
557 def describe_volumes(self, context, volume_id=None, filter=None,
558 **kwargs):
559 predicate = self._map_ec2_filter_to_predicate(filter)
537 if volume_id:560 if volume_id:
561 #TODO(justinsb): We probably should support filters here
562 # but filters with specified ids are a very odd request
538 volumes = []563 volumes = []
539 for ec2_id in volume_id:564 for ec2_id in volume_id:
540 internal_id = ec2utils.ec2_id_to_id(ec2_id)565 internal_id = ec2utils.ec2_id_to_id(ec2_id)
541 volume = self.volume_api.get(context, internal_id)566 volume = self.volume_api.get(context, internal_id)
542 volumes.append(volume)567 volumes.append(volume)
543 else:568 else:
544 volumes = self.volume_api.get_all(context)569 volumes = self.volume_api.get_all(context, predicate)
545 volumes = [self._format_volume(context, v) for v in volumes]570 volumes = [self._format_volume(context, v) for v in volumes]
546 return {'volumeSet': volumes}571 return {'volumeSet': volumes}
547572
548573
=== added file 'nova/api/predicates.py'
--- nova/api/predicates.py 1970-01-01 00:00:00 +0000
+++ nova/api/predicates.py 2011-03-14 15:58:21 +0000
@@ -0,0 +1,133 @@
1# vim: tabstop=4 shiftwidth=4 softtabstop=4
2
3# Copyright 2011 Justin Santa Barbara
4# All Rights Reserved.
5#
6# Licensed under the Apache License, Version 2.0 (the "License"); you may
7# not use this file except in compliance with the License. You may obtain
8# a copy of the License at
9#
10# http://www.apache.org/licenses/LICENSE-2.0
11#
12# Unless required by applicable law or agreed to in writing, software
13# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
14# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
15# License for the specific language governing permissions and limitations
16# under the License.
17
18"""Model classes that represent predicates"""
19
20from nova import log as logging
21
22
23LOG = logging.getLogger('nova.api.predicates')
24
25# Well known properties
26PROPERTY_PROJECT_ID = 'project_id'
27
28
29class Predicate(object):
30 """A predicate is a logical function which is either true or false.
31
32 We use it for filtering values"""
33 def __init__(self):
34 pass
35
36 def join_and(self, rhs):
37 """Returns a predicate which is true iff both this and rhs are true"""
38 return CompoundAndPredicate([self, rhs])
39
40 def filter_by_project(self, project_id):
41 """Adds a predicate so we only match the specified project"""
42 project_filter = ValuePredicate(PROPERTY_PROJECT_ID, project_id)
43 return self.join_and(project_filter)
44
45 def collapse_to_filters(self):
46 """Collapse to a dictionary of key => value_list filters.
47
48 If the predicate is unsatisfiable, return False.
49 If the predicate cannot be simplified in this way, raise an exception.
50 The predicate should be true for an object o iff
51 for each k,v in map: o[k] in v
52 """
53 raise NotImplementedError()
54
55
56class ValuePredicate(Predicate):
57 """A predicate on a single property
58
59 The predicate is satisfied if the value is a member of acceptable_values
60 """
61 def __init__(self, key, acceptable_values):
62 super(ValuePredicate, self).__init__()
63 self.key = key
64 if not isinstance(acceptable_values, list):
65 raise Exception("acceptable_values must be a list")
66 self.acceptable_values = acceptable_values
67
68 def collapse_to_filters(self):
69 collapsed = {}
70 collapsed[self.key] = self.acceptable_values
71 return collapsed
72
73
74def _intersect(lhs, rhs):
75 """Returns the list of items in both lhs and rhs"""
76 return list(set(lhs) & set(rhs))
77
78
79class CompoundAndPredicate(Predicate):
80 """A predicate where all children must be satisfied"""
81 def __init__(self, children):
82 super(CompoundAndPredicate, self).__init__()
83 self.children = children
84
85 def join_and(self, rhs):
86 #NOTE(justinsb): We could do some immediate-simplification here
87 # e.g. if rhs is a CompoundAndPredicate
88 conjunction = self.children + rhs
89 return CompoundAndPredicate(conjunction)
90
91 def collapse_to_filters(self):
92 collapsed = {}
93 for child in self.children:
94 child_map = child.collapse_to_filters()
95 if child_map == False:
96 return False
97
98 for k, v in child_map.iteritems():
99 values = collapsed.get(k)
100 if values is None:
101 values = v
102 else:
103 values = _intersect(values, v)
104 if not values:
105 # Unsatisfiable (something like X=A and X=B, A != B)
106 return False
107
108 collapsed[k] = values
109
110 return collapsed
111
112
113class FalsePredicate(Predicate):
114 """A predicate which is always False"""
115
116 def join_and(self, rhs):
117 # We are never true, so our conjunction can never be true
118 return FalsePredicate()
119
120 def collapse_to_filters(self):
121 # Unsatisfiable, return False
122 return False
123
124
125class TruePredicate(Predicate):
126 """A predicate which is always True"""
127
128 def join_and(self, rhs):
129 return rhs
130
131 def collapse_to_filters(self):
132 # No criteria
133 return {}
0134
=== modified file 'nova/db/api.py'
--- nova/db/api.py 2011-03-09 21:27:38 +0000
+++ nova/db/api.py 2011-03-14 15:58:21 +0000
@@ -770,6 +770,11 @@
770 return IMPL.volume_get_all_by_project(context, project_id)770 return IMPL.volume_get_all_by_project(context, project_id)
771771
772772
773def volume_get_predicated(context, predicate):
774 """Get a list of volumes matching the specified predicate."""
775 return IMPL.volume_get_predicated(context, predicate)
776
777
773def volume_get_by_ec2_id(context, ec2_id):778def volume_get_by_ec2_id(context, ec2_id):
774 """Get a volume by ec2 id."""779 """Get a volume by ec2 id."""
775 return IMPL.volume_get_by_ec2_id(context, ec2_id)780 return IMPL.volume_get_by_ec2_id(context, ec2_id)
776781
=== modified file 'nova/db/sqlalchemy/api.py'
--- nova/db/sqlalchemy/api.py 2011-03-11 23:05:02 +0000
+++ nova/db/sqlalchemy/api.py 2011-03-14 15:58:21 +0000
@@ -2,6 +2,7 @@
22
3# Copyright 2010 United States Government as represented by the3# Copyright 2010 United States Government as represented by the
4# Administrator of the National Aeronautics and Space Administration.4# Administrator of the National Aeronautics and Space Administration.
5# Copyright 2011 Justin Santa Barbara
5# All Rights Reserved.6# All Rights Reserved.
6#7#
7# Licensed under the Apache License, Version 2.0 (the "License"); you may8# Licensed under the Apache License, Version 2.0 (the "License"); you may
@@ -25,7 +26,9 @@
25from nova import db26from nova import db
26from nova import exception27from nova import exception
27from nova import flags28from nova import flags
29from nova import log as logging
28from nova import utils30from nova import utils
31from nova.api import predicates
29from nova.db.sqlalchemy import models32from nova.db.sqlalchemy import models
30from nova.db.sqlalchemy.session import get_session33from nova.db.sqlalchemy.session import get_session
31from sqlalchemy import or_34from sqlalchemy import or_
@@ -36,8 +39,11 @@
36from sqlalchemy.sql import func39from sqlalchemy.sql import func
37from sqlalchemy.sql.expression import literal_column40from sqlalchemy.sql.expression import literal_column
3841
42
39FLAGS = flags.FLAGS43FLAGS = flags.FLAGS
4044
45LOG = logging.getLogger('nova.db.sqlalchemy')
46
4147
42def is_admin_context(context):48def is_admin_context(context):
43 """Indicates if the request context is an administrator."""49 """Indicates if the request context is an administrator."""
@@ -1511,6 +1517,49 @@
1511 return result1517 return result
15121518
15131519
1520@require_context
1521def volume_get_predicated(context, predicate=None):
1522 session = get_session()
1523 query = session.query(models.Volume).\
1524 options(joinedload('instance')).\
1525 filter_by(deleted=can_read_deleted(context))
1526
1527 if predicate:
1528 filters = predicate.collapse_to_filters()
1529 LOG.debug("filters=%s", filters)
1530 else:
1531 filters = {}
1532
1533 # If the predicate is known-unsatisfiable, don't bother hitting the DB
1534 if filters == False:
1535 return []
1536
1537 filtered_project_id = None
1538
1539 # For now, we whitelist/hard-code the filters...
1540 for k, v in filters.iteritems():
1541 if k == predicates.PROPERTY_PROJECT_ID:
1542 for project_id in v:
1543 authorize_project_context(context, project_id)
1544
1545 #TODO(justinsb): Does SA simplify a single-item IN clause?
1546 if len(v) == 1:
1547 query = query.filter(models.Volume.project_id == v[0])
1548 else:
1549 query = query.filter(models.Volume.project_id.in_(v))
1550
1551 filtered_project_id = v
1552 else:
1553 raise exception.ApiError(_("Unknown predicate: %s") % k)
1554
1555 # Only an admin can do a query without a project filter
1556 if not filtered_project_id:
1557 if not is_admin_context(context):
1558 raise exception.NotAuthorized()
1559
1560 return query.all()
1561
1562
1514@require_admin_context1563@require_admin_context
1515def volume_get_all(context):1564def volume_get_all(context):
1516 session = get_session()1565 session = get_session()
15171566
=== modified file 'nova/tests/test_cloud.py'
--- nova/tests/test_cloud.py 2011-03-10 04:42:11 +0000
+++ nova/tests/test_cloud.py 2011-03-14 15:58:21 +0000
@@ -30,6 +30,7 @@
30from nova import context30from nova import context
31from nova import crypto31from nova import crypto
32from nova import db32from nova import db
33from nova import exception
33from nova import flags34from nova import flags
34from nova import log as logging35from nova import log as logging
35from nova import rpc36from nova import rpc
@@ -176,6 +177,44 @@
176 db.volume_destroy(self.context, vol1['id'])177 db.volume_destroy(self.context, vol1['id'])
177 db.volume_destroy(self.context, vol2['id'])178 db.volume_destroy(self.context, vol2['id'])
178179
180 def test_describe_volumes_filters(self):
181 """Makes sure describe_volumes works and filters results."""
182 vol1 = db.volume_create(self.context, {'project_id': 'proj1'})
183 vol2 = db.volume_create(self.context, {'project_id': 'proj2'})
184
185 filtr = [{ 'name': 'project_id', 'value': {'1': 'proj1'} }]
186 result = self.cloud.describe_volumes(self.context, filter=filtr)
187 self.assertEqual(len(result['volumeSet']), 1)
188
189 filtr = [{ 'name': 'project_id', 'value': {'1': 'proj2'} }]
190 result = self.cloud.describe_volumes(self.context, filter=filtr)
191 self.assertEqual(len(result['volumeSet']), 1)
192
193 filtr = [{ 'name': 'project_id', 'value': {'1': 'proj3'} }]
194 result = self.cloud.describe_volumes(self.context, filter=filtr)
195 self.assertEqual(len(result['volumeSet']), 0)
196
197 filtr = [{ 'name': 'project_id', 'value': {'1': 'proj1',
198 '2': 'proj2'} }]
199 result = self.cloud.describe_volumes(self.context, filter=filtr)
200 self.assertEqual(len(result['volumeSet']), 2)
201
202 # Unsatisfiable (shouldn't even hit DB!)
203 filtr = [{ 'name': 'project_id', 'value': {'1': 'proj1'} },
204 { 'name': 'project_id', 'value': {'1': 'proj2'} },
205 ]
206 result = self.cloud.describe_volumes(self.context, filter=filtr)
207 self.assertEqual(len(result['volumeSet']), 0)
208
209 # Not a supported value
210 filtr = [{ 'name': 'notso_key', 'value': {'1': 'notso_value'} }]
211 self.assertRaises(exception.ApiError,
212 self.cloud.describe_volumes,
213 self.context, filter=filtr)
214
215 db.volume_destroy(self.context, vol1['id'])
216 db.volume_destroy(self.context, vol2['id'])
217
179 def test_describe_availability_zones(self):218 def test_describe_availability_zones(self):
180 """Makes sure describe_availability_zones works and filters results."""219 """Makes sure describe_availability_zones works and filters results."""
181 service1 = db.service_create(self.context, {'host': 'host1_zones',220 service1 = db.service_create(self.context, {'host': 'host1_zones',
182221
=== modified file 'nova/volume/api.py'
--- nova/volume/api.py 2011-02-19 07:15:42 +0000
+++ nova/volume/api.py 2011-03-14 15:58:21 +0000
@@ -2,6 +2,7 @@
22
3# Copyright 2010 United States Government as represented by the3# Copyright 2010 United States Government as represented by the
4# Administrator of the National Aeronautics and Space Administration.4# Administrator of the National Aeronautics and Space Administration.
5# Copyright 2011 Justin Santa Barbara
5# All Rights Reserved.6# All Rights Reserved.
6#7#
7# Licensed under the Apache License, Version 2.0 (the "License"); you may8# Licensed under the Apache License, Version 2.0 (the "License"); you may
@@ -28,6 +29,7 @@
28from nova import log as logging29from nova import log as logging
29from nova import quota30from nova import quota
30from nova import rpc31from nova import rpc
32from nova.api import predicates
31from nova.db import base33from nova.db import base
3234
33FLAGS = flags.FLAGS35FLAGS = flags.FLAGS
@@ -84,10 +86,14 @@
84 def get(self, context, volume_id):86 def get(self, context, volume_id):
85 return self.db.volume_get(context, volume_id)87 return self.db.volume_get(context, volume_id)
8688
87 def get_all(self, context):89 def get_all(self, context, predicate=None):
88 if context.is_admin:90 if predicate is None:
89 return self.db.volume_get_all(context)91 predicate = predicates.TruePredicate()
90 return self.db.volume_get_all_by_project(context, context.project_id)92
93 if not context.is_admin:
94 predicate = predicate.filter_by_project(context.project_id)
95
96 return self.db.volume_get_predicated(context, predicate)
9197
92 def check_attach(self, context, volume_id):98 def check_attach(self, context, volume_id):
93 volume = self.get(context, volume_id)99 volume = self.get(context, volume_id)