summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorKevin Benton <kevin@benton.pub>2016-12-11 18:24:01 -0800
committerKevin Benton <kevin@benton.pub>2017-06-29 22:50:12 +0000
commit7f17b4759e41aa0a922ac27c2dabc595e7d2f67c (patch)
treec2ffca935327f5bbe3038f490880691764bb3533
parent9c0a0de556f8edafd153a7d4c1c0548dd4ff3129 (diff)
API compare-and-swap updates based on revision_number
Allows posting revision number matching in the If-Match header so updates/deletes will only be satisfied if the current revision number of the object matches. DocImpact: The Neutron API now supports conditional updates to resources that contain the standard 'revision_number' attribute by setting the revision_number in an HTTP If-Match header. APIImpact Partial-Bug: #1493714 Partially-Implements: blueprint push-notifications Change-Id: I7d97d6044378eb59cb2c7bdc788dc6c174783299
Notes
Notes (review): Verified+1: Mellanox CI <mlnx-openstack-ci@dev.mellanox.co.il> Code-Review+2: Ihar Hrachyshka <ihrachys@redhat.com> Code-Review+2: YAMAMOTO Takashi <yamamoto@midokura.com> Workflow+1: YAMAMOTO Takashi <yamamoto@midokura.com> Verified+2: Jenkins Submitted-by: Jenkins Submitted-at: Sun, 09 Jul 2017 18:17:59 +0000 Reviewed-on: https://review.openstack.org/409577 Project: openstack/neutron Branch: refs/heads/master
-rw-r--r--neutron/api/api_common.py29
-rw-r--r--neutron/api/v2/resource.py9
-rw-r--r--neutron/extensions/revisionifmatch.py42
-rw-r--r--neutron/pecan_wsgi/hooks/query_parameters.py18
-rw-r--r--neutron/services/revisions/revision_plugin.py73
-rw-r--r--neutron/tests/functional/pecan_wsgi/test_hooks.py46
-rw-r--r--neutron/tests/tempest/api/test_revisions.py30
-rw-r--r--neutron/tests/tempest/services/network/json/network_client.py3
-rw-r--r--neutron/tests/unit/db/test_db_base_plugin_v2.py24
-rw-r--r--neutron/tests/unit/services/revisions/test_revision_plugin.py53
-rw-r--r--neutron/tests/unit/testlib_api.py4
-rw-r--r--releasenotes/notes/conditional_updates-10b9aa66fd144217.yaml14
12 files changed, 332 insertions, 13 deletions
diff --git a/neutron/api/api_common.py b/neutron/api/api_common.py
index 6dd9493..17998bd 100644
--- a/neutron/api/api_common.py
+++ b/neutron/api/api_common.py
@@ -27,6 +27,7 @@ from six.moves.urllib import parse
27from webob import exc 27from webob import exc
28 28
29from neutron._i18n import _, _LW 29from neutron._i18n import _, _LW
30from neutron.api import extensions
30from neutron.common import constants 31from neutron.common import constants
31from neutron import wsgi 32from neutron import wsgi
32 33
@@ -34,6 +35,34 @@ from neutron import wsgi
34LOG = logging.getLogger(__name__) 35LOG = logging.getLogger(__name__)
35 36
36 37
38def ensure_if_match_supported():
39 """Raises exception if 'if-match' revision matching unsupported."""
40 if 'revision-if-match' in (extensions.PluginAwareExtensionManager.
41 get_instance().extensions):
42 return
43 msg = _("This server does not support constraining operations based on "
44 "revision numbers")
45 raise exceptions.BadRequest(resource='if-match', msg=msg)
46
47
48def check_request_for_revision_constraint(request):
49 """Parses, verifies, and returns a constraint from a request."""
50 revision_number = None
51 for e in getattr(request.if_match, 'etags', []):
52 if e.startswith('revision_number='):
53 if revision_number is not None:
54 msg = _("Multiple revision_number etags are not supported.")
55 raise exceptions.BadRequest(resource='if-match', msg=msg)
56 ensure_if_match_supported()
57 try:
58 revision_number = int(e.split('revision_number=')[1])
59 except ValueError:
60 msg = _("Revision number etag must be in the format of "
61 "revision_number=<int>")
62 raise exceptions.BadRequest(resource='if-match', msg=msg)
63 return revision_number
64
65
37def get_filters(request, attr_info, skips=None): 66def get_filters(request, attr_info, skips=None):
38 return get_filters_from_dict(request.GET.dict_of_lists(), 67 return get_filters_from_dict(request.GET.dict_of_lists(),
39 attr_info, 68 attr_info,
diff --git a/neutron/api/v2/resource.py b/neutron/api/v2/resource.py
index e1438d3..92977cc 100644
--- a/neutron/api/v2/resource.py
+++ b/neutron/api/v2/resource.py
@@ -89,6 +89,15 @@ def Resource(controller, faults=None, deserializers=None, serializers=None,
89 if fmt is not None and fmt not in format_types: 89 if fmt is not None and fmt not in format_types:
90 args['id'] = '.'.join([args['id'], fmt]) 90 args['id'] = '.'.join([args['id'], fmt])
91 91
92 revision_number = api_common.check_request_for_revision_constraint(
93 request)
94 if revision_number is not None:
95 constraint = {'if_revision_match': revision_number,
96 'resource': controller._collection,
97 'resource_id': args['id']}
98 # TODO(kevinbenton): add an interface to context to do this
99 setattr(request.context, '_CONSTRAINT', constraint)
100
92 method = getattr(controller, action) 101 method = getattr(controller, action)
93 result = method(request=request, **args) 102 result = method(request=request, **args)
94 except Exception as e: 103 except Exception as e:
diff --git a/neutron/extensions/revisionifmatch.py b/neutron/extensions/revisionifmatch.py
new file mode 100644
index 0000000..610d30e
--- /dev/null
+++ b/neutron/extensions/revisionifmatch.py
@@ -0,0 +1,42 @@
1# Licensed under the Apache License, Version 2.0 (the "License");
2# you may not use this file except in compliance with the License.
3# You may obtain a copy of the License at
4#
5# http://www.apache.org/licenses/LICENSE-2.0
6#
7# Unless required by applicable law or agreed to in writing, software
8# distributed under the License is distributed on an "AS IS" BASIS,
9# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
10# implied.
11# See the License for the specific language governing permissions and
12# limitations under the License.
13
14from neutron_lib.api import extensions as api_extensions
15
16
17class Revisionifmatch(api_extensions.ExtensionDescriptor):
18 """Indicate that If-Match constraints on revision_number are supported."""
19
20 @classmethod
21 def get_name(cls):
22 return "If-Match constraints based on revision_number"
23
24 @classmethod
25 def get_alias(cls):
26 return 'revision-if-match'
27
28 @classmethod
29 def get_description(cls):
30 return ("Extension indicating that If-Match based on revision_number "
31 "is supported.")
32
33 @classmethod
34 def get_updated(cls):
35 return "2016-12-11T00:00:00-00:00"
36
37 @classmethod
38 def get_resources(cls):
39 return []
40
41 def get_extended_resources(self, version):
42 return {}
diff --git a/neutron/pecan_wsgi/hooks/query_parameters.py b/neutron/pecan_wsgi/hooks/query_parameters.py
index 6cf63cc..68ada62 100644
--- a/neutron/pecan_wsgi/hooks/query_parameters.py
+++ b/neutron/pecan_wsgi/hooks/query_parameters.py
@@ -88,6 +88,7 @@ class QueryParametersHook(hooks.PecanHook):
88 priority = policy_enforcement.PolicyHook.priority - 1 88 priority = policy_enforcement.PolicyHook.priority - 1
89 89
90 def before(self, state): 90 def before(self, state):
91 self._process_if_match_headers(state)
91 state.request.context['query_params'] = {} 92 state.request.context['query_params'] = {}
92 if state.request.method != 'GET': 93 if state.request.method != 'GET':
93 return 94 return
@@ -108,6 +109,23 @@ class QueryParametersHook(hooks.PecanHook):
108 added_fields) 109 added_fields)
109 state.request.context['query_params'] = query_params 110 state.request.context['query_params'] = query_params
110 111
112 def _process_if_match_headers(self, state):
113 collection = state.request.context.get('collection')
114 if not collection:
115 return
116 # add in if-match criterion to the context if present
117 revision_number = api_common.check_request_for_revision_constraint(
118 state.request)
119 if revision_number is None:
120 return
121 constraint = {
122 'if_revision_match': revision_number,
123 'resource': collection,
124 'resource_id': state.request.context['resource_id']}
125 # TODO(kevinbenton): add an interface to context to do this
126 setattr(state.request.context['neutron_context'],
127 '_CONSTRAINT', constraint)
128
111 def after(self, state): 129 def after(self, state):
112 resource = state.request.context.get('resource') 130 resource = state.request.context.get('resource')
113 collection = state.request.context.get('collection') 131 collection = state.request.context.get('collection')
diff --git a/neutron/services/revisions/revision_plugin.py b/neutron/services/revisions/revision_plugin.py
index ef48689..9e66b430 100644
--- a/neutron/services/revisions/revision_plugin.py
+++ b/neutron/services/revisions/revision_plugin.py
@@ -16,6 +16,7 @@ from oslo_log import log as logging
16import sqlalchemy 16import sqlalchemy
17from sqlalchemy.orm import exc 17from sqlalchemy.orm import exc
18from sqlalchemy.orm import session as se 18from sqlalchemy.orm import session as se
19import webob.exc
19 20
20from neutron._i18n import _, _LW 21from neutron._i18n import _, _LW
21from neutron.db import _resource_extend as resource_extend 22from neutron.db import _resource_extend as resource_extend
@@ -29,7 +30,8 @@ LOG = logging.getLogger(__name__)
29class RevisionPlugin(service_base.ServicePluginBase): 30class RevisionPlugin(service_base.ServicePluginBase):
30 """Plugin to populate revision numbers into standard attr resources.""" 31 """Plugin to populate revision numbers into standard attr resources."""
31 32
32 supported_extension_aliases = ['standard-attr-revisions'] 33 supported_extension_aliases = ['standard-attr-revisions',
34 'revision-if-match']
33 35
34 def __init__(self): 36 def __init__(self):
35 super(RevisionPlugin, self).__init__() 37 super(RevisionPlugin, self).__init__()
@@ -40,6 +42,7 @@ class RevisionPlugin(service_base.ServicePluginBase):
40 self._clear_rev_bumped_flags) 42 self._clear_rev_bumped_flags)
41 43
42 def bump_revisions(self, session, context, instances): 44 def bump_revisions(self, session, context, instances):
45 self._enforce_if_match_constraints(session)
43 # bump revision number for any updated objects in the session 46 # bump revision number for any updated objects in the session
44 for obj in session.dirty: 47 for obj in session.dirty:
45 if isinstance(obj, standard_attr.HasStandardAttributes): 48 if isinstance(obj, standard_attr.HasStandardAttributes):
@@ -117,5 +120,73 @@ class RevisionPlugin(service_base.ServicePluginBase):
117 if getattr(obj, '_rev_bumped', False): 120 if getattr(obj, '_rev_bumped', False):
118 # we've already bumped the revision of this object in this txn 121 # we've already bumped the revision of this object in this txn
119 return 122 return
123 instance, match = self._get_constrained_instance_match(session)
124 if instance and instance == obj:
125 # one last check before bumping revision
126 self._enforce_if_match_constraints(session)
120 obj.bump_revision() 127 obj.bump_revision()
121 setattr(obj, '_rev_bumped', True) 128 setattr(obj, '_rev_bumped', True)
129
130 def _find_instance_by_column_value(self, session, model, column, value):
131 """Lookup object in session or from DB based on a column's value."""
132 for session_obj in session:
133 if not isinstance(session_obj, model):
134 continue
135 if getattr(session_obj, column) == value:
136 return session_obj
137 # object isn't in session so we have to query for it
138 related_obj = (session.query(model).filter_by(**{column: value}).
139 first())
140 return related_obj
141
142 def _get_constrained_instance_match(self, session):
143 """Returns instance and constraint of if-match criterion if present.
144
145 Checks the context associated with the session for compare-and-swap
146 update revision number constraints. If one is found, this returns the
147 instance that is constrained as well as the requested revision number
148 to match.
149 """
150 criteria = getattr(session.info.get('using_context'),
151 '_CONSTRAINT', None)
152 if not criteria:
153 return None, None
154 match = criteria['if_revision_match']
155 mmap = standard_attr.get_standard_attr_resource_model_map()
156 model = mmap.get(criteria['resource'])
157 if not model:
158 msg = _("Revision matching not supported for this resource")
159 raise exc.BadRequest(resource=criteria['resource'], msg=msg)
160 instance = self._find_instance_by_column_value(
161 session, model, 'id', criteria['resource_id'])
162 return instance, match
163
164 def _enforce_if_match_constraints(self, session):
165 """Check for if-match constraints and raise exception if violated.
166
167 We determine the collection being modified and look for any
168 objects of the collection type in the dirty/deleted items in
169 the session. If they don't match the revision_number constraint
170 supplied, we throw an exception.
171
172 We are protected from a concurrent update because if we match
173 revision number here and another update commits to the database
174 first, the compare and swap of revision_number will fail and a
175 StaleDataError (or deadlock in galera multi-writer) will be raised,
176 at which point this will be retried and fail to match.
177 """
178 instance, match = self._get_constrained_instance_match(session)
179 if not instance or getattr(instance, '_rev_bumped', False):
180 # no constraints present or constrain satisfied in this transaction
181 return
182 if instance.revision_number != match:
183 raise RevisionNumberConstraintFailed(match,
184 instance.revision_number)
185
186
187class RevisionNumberConstraintFailed(webob.exc.HTTPPreconditionFailed):
188
189 def __init__(self, expected, current):
190 detail = (_("Constrained to %(exp)s, but current revision is %(cur)s")
191 % {'exp': expected, 'cur': current})
192 super(RevisionNumberConstraintFailed, self).__init__(detail=detail)
diff --git a/neutron/tests/functional/pecan_wsgi/test_hooks.py b/neutron/tests/functional/pecan_wsgi/test_hooks.py
index 63b689f..0b4a35d 100644
--- a/neutron/tests/functional/pecan_wsgi/test_hooks.py
+++ b/neutron/tests/functional/pecan_wsgi/test_hooks.py
@@ -18,6 +18,7 @@ from neutron_lib.callbacks import events
18from neutron_lib import context 18from neutron_lib import context
19from neutron_lib.db import constants as db_const 19from neutron_lib.db import constants as db_const
20from neutron_lib.plugins import directory 20from neutron_lib.plugins import directory
21from oslo_config import cfg
21from oslo_policy import policy as oslo_policy 22from oslo_policy import policy as oslo_policy
22from oslo_serialization import jsonutils 23from oslo_serialization import jsonutils
23 24
@@ -45,6 +46,51 @@ class TestOwnershipHook(test_functional.PecanFunctionalTest):
45 self.assertEqual(201, port_response.status_int) 46 self.assertEqual(201, port_response.status_int)
46 47
47 48
49class TestQueryParamatersHook(test_functional.PecanFunctionalTest):
50
51 def test_if_match_on_update(self):
52 net_response = jsonutils.loads(self.app.post_json(
53 '/v2.0/networks.json',
54 params={'network': {'name': 'meh'}},
55 headers={'X-Project-Id': 'tenid'}).body)
56 network_id = net_response['network']['id']
57 response = self.app.put_json('/v2.0/networks/%s.json' % network_id,
58 params={'network': {'name': 'cat'}},
59 headers={'X-Project-Id': 'tenid',
60 'If-Match': 'revision_number=0'},
61 expect_errors=True)
62 # revision plugin not supported by default, so badrequest
63 self.assertEqual(400, response.status_int)
64
65
66class TestQueryParamatersHookWithRevision(test_functional.PecanFunctionalTest):
67
68 def setUp(self):
69 cfg.CONF.set_override('service_plugins', ['revisions'])
70 super(TestQueryParamatersHookWithRevision, self).setUp()
71
72 def test_if_match_on_update(self):
73 net_response = jsonutils.loads(self.app.post_json(
74 '/v2.0/networks.json',
75 params={'network': {'name': 'meh'}},
76 headers={'X-Project-Id': 'tenid'}).body)
77 network_id = net_response['network']['id']
78 rev = net_response['network']['revision_number']
79 stale = rev - 1
80
81 response = self.app.put_json(
82 '/v2.0/networks/%s.json' % network_id,
83 params={'network': {'name': 'cat'}},
84 headers={'X-Project-Id': 'tenid',
85 'If-Match': 'revision_number=%s' % stale},
86 expect_errors=True)
87 self.assertEqual(412, response.status_int)
88 self.app.put_json('/v2.0/networks/%s.json' % network_id,
89 params={'network': {'name': 'cat'}},
90 headers={'X-Project-Id': 'tenid',
91 'If-Match': 'revision_number=%s' % rev})
92
93
48class TestQuotaEnforcementHook(test_functional.PecanFunctionalTest): 94class TestQuotaEnforcementHook(test_functional.PecanFunctionalTest):
49 95
50 def test_quota_enforcement_single(self): 96 def test_quota_enforcement_single(self):
diff --git a/neutron/tests/tempest/api/test_revisions.py b/neutron/tests/tempest/api/test_revisions.py
index d94aede..83c8410 100644
--- a/neutron/tests/tempest/api/test_revisions.py
+++ b/neutron/tests/tempest/api/test_revisions.py
@@ -13,6 +13,7 @@
13import netaddr 13import netaddr
14 14
15from tempest.lib import decorators 15from tempest.lib import decorators
16from tempest.lib import exceptions
16from tempest import test 17from tempest import test
17 18
18from neutron.tests.tempest.api import base 19from neutron.tests.tempest.api import base
@@ -33,6 +34,35 @@ class TestRevisions(base.BaseAdminNetworkTest, bsg.BaseSecGroupTest):
33 self.assertGreater(updated['network']['revision_number'], 34 self.assertGreater(updated['network']['revision_number'],
34 net['revision_number']) 35 net['revision_number'])
35 36
37 @decorators.idempotent_id('4a26a4be-9c53-483c-bc50-b11111113333')
38 def test_update_network_constrained_by_revision(self):
39 net = self.create_network()
40 current = net['revision_number']
41 stale = current - 1
42 # using a stale number should fail
43 self.assertRaises(
44 exceptions.PreconditionFailed,
45 self.client.update_network,
46 net['id'], name='newnet',
47 headers={'If-Match': 'revision_number=%s' % stale}
48 )
49
50 # using current should pass. in case something is updating the network
51 # on the server at the same time, we have to re-read and update to be
52 # safe
53 for i in range(100):
54 current = (self.client.show_network(net['id'])
55 ['network']['revision_number'])
56 try:
57 self.client.update_network(
58 net['id'], name='newnet',
59 headers={'If-Match': 'revision_number=%s' % current})
60 except exceptions.UnexpectedResponseCode:
61 continue
62 break
63 else:
64 self.fail("Failed to update network after 100 tries.")
65
36 @decorators.idempotent_id('cac7ecde-12d5-4331-9a03-420899dea077') 66 @decorators.idempotent_id('cac7ecde-12d5-4331-9a03-420899dea077')
37 def test_update_port_bumps_revision(self): 67 def test_update_port_bumps_revision(self):
38 net = self.create_network() 68 net = self.create_network()
diff --git a/neutron/tests/tempest/services/network/json/network_client.py b/neutron/tests/tempest/services/network/json/network_client.py
index f5621ea..7a05da1 100644
--- a/neutron/tests/tempest/services/network/json/network_client.py
+++ b/neutron/tests/tempest/services/network/json/network_client.py
@@ -148,10 +148,11 @@ class NetworkClientJSON(service_client.RestClient):
148 148
149 def _updater(self, resource_name): 149 def _updater(self, resource_name):
150 def _update(res_id, **kwargs): 150 def _update(res_id, **kwargs):
151 headers = kwargs.pop('headers', {})
151 plural = self.pluralize(resource_name) 152 plural = self.pluralize(resource_name)
152 uri = '%s/%s' % (self.get_uri(plural), res_id) 153 uri = '%s/%s' % (self.get_uri(plural), res_id)
153 post_data = self.serialize({resource_name: kwargs}) 154 post_data = self.serialize({resource_name: kwargs})
154 resp, body = self.put(uri, post_data) 155 resp, body = self.put(uri, post_data, headers=headers)
155 body = self.deserialize_single(body) 156 body = self.deserialize_single(body)
156 self.expected_success(200, resp.status) 157 self.expected_success(200, resp.status)
157 return service_client.ResponseBody(resp, body) 158 return service_client.ResponseBody(resp, body)
diff --git a/neutron/tests/unit/db/test_db_base_plugin_v2.py b/neutron/tests/unit/db/test_db_base_plugin_v2.py
index d9db78b..9b927ad 100644
--- a/neutron/tests/unit/db/test_db_base_plugin_v2.py
+++ b/neutron/tests/unit/db/test_db_base_plugin_v2.py
@@ -177,7 +177,8 @@ class NeutronDbPluginV2TestCase(testlib_api.WebTestCase):
177 super(NeutronDbPluginV2TestCase, self).setup_config(args=args) 177 super(NeutronDbPluginV2TestCase, self).setup_config(args=args)
178 178
179 def _req(self, method, resource, data=None, fmt=None, id=None, params=None, 179 def _req(self, method, resource, data=None, fmt=None, id=None, params=None,
180 action=None, subresource=None, sub_id=None, context=None): 180 action=None, subresource=None, sub_id=None, context=None,
181 headers=None):
181 fmt = fmt or self.fmt 182 fmt = fmt or self.fmt
182 183
183 path = '/%s.%s' % ( 184 path = '/%s.%s' % (
@@ -195,7 +196,8 @@ class NeutronDbPluginV2TestCase(testlib_api.WebTestCase):
195 if data is not None: # empty dict is valid 196 if data is not None: # empty dict is valid
196 body = self.serialize(data) 197 body = self.serialize(data)
197 return testlib_api.create_request(path, body, content_type, method, 198 return testlib_api.create_request(path, body, content_type, method,
198 query_string=params, context=context) 199 query_string=params, context=context,
200 headers=headers)
199 201
200 def new_create_request(self, resource, data, fmt=None, id=None, 202 def new_create_request(self, resource, data, fmt=None, id=None,
201 subresource=None, context=None): 203 subresource=None, context=None):
@@ -218,7 +220,7 @@ class NeutronDbPluginV2TestCase(testlib_api.WebTestCase):
218 params=params, subresource=subresource, sub_id=sub_id) 220 params=params, subresource=subresource, sub_id=sub_id)
219 221
220 def new_delete_request(self, resource, id, fmt=None, subresource=None, 222 def new_delete_request(self, resource, id, fmt=None, subresource=None,
221 sub_id=None, data=None): 223 sub_id=None, data=None, headers=None):
222 return self._req( 224 return self._req(
223 'DELETE', 225 'DELETE',
224 resource, 226 resource,
@@ -226,14 +228,16 @@ class NeutronDbPluginV2TestCase(testlib_api.WebTestCase):
226 fmt, 228 fmt,
227 id=id, 229 id=id,
228 subresource=subresource, 230 subresource=subresource,
229 sub_id=sub_id 231 sub_id=sub_id,
232 headers=headers
230 ) 233 )
231 234
232 def new_update_request(self, resource, data, id, fmt=None, 235 def new_update_request(self, resource, data, id, fmt=None,
233 subresource=None, context=None, sub_id=None): 236 subresource=None, context=None, sub_id=None,
237 headers=None):
234 return self._req( 238 return self._req(
235 'PUT', resource, data, fmt, id=id, subresource=subresource, 239 'PUT', resource, data, fmt, id=id, subresource=subresource,
236 sub_id=sub_id, context=context 240 sub_id=sub_id, context=context, headers=headers
237 ) 241 )
238 242
239 def new_action_request(self, resource, data, id, action, fmt=None, 243 def new_action_request(self, resource, data, id, action, fmt=None,
@@ -519,8 +523,8 @@ class NeutronDbPluginV2TestCase(testlib_api.WebTestCase):
519 523
520 def _delete(self, collection, id, 524 def _delete(self, collection, id,
521 expected_code=webob.exc.HTTPNoContent.code, 525 expected_code=webob.exc.HTTPNoContent.code,
522 neutron_context=None): 526 neutron_context=None, headers=None):
523 req = self.new_delete_request(collection, id) 527 req = self.new_delete_request(collection, id, headers=headers)
524 if neutron_context: 528 if neutron_context:
525 # create a specific auth context for this request 529 # create a specific auth context for this request
526 req.environ['neutron.context'] = neutron_context 530 req.environ['neutron.context'] = neutron_context
@@ -544,8 +548,8 @@ class NeutronDbPluginV2TestCase(testlib_api.WebTestCase):
544 548
545 def _update(self, resource, id, new_data, 549 def _update(self, resource, id, new_data,
546 expected_code=webob.exc.HTTPOk.code, 550 expected_code=webob.exc.HTTPOk.code,
547 neutron_context=None): 551 neutron_context=None, headers=None):
548 req = self.new_update_request(resource, new_data, id) 552 req = self.new_update_request(resource, new_data, id, headers=headers)
549 if neutron_context: 553 if neutron_context:
550 # create a specific auth context for this request 554 # create a specific auth context for this request
551 req.environ['neutron.context'] = neutron_context 555 req.environ['neutron.context'] = neutron_context
diff --git a/neutron/tests/unit/services/revisions/test_revision_plugin.py b/neutron/tests/unit/services/revisions/test_revision_plugin.py
index ff96998..edcf65c 100644
--- a/neutron/tests/unit/services/revisions/test_revision_plugin.py
+++ b/neutron/tests/unit/services/revisions/test_revision_plugin.py
@@ -17,8 +17,12 @@ import netaddr
17from neutron_lib import constants 17from neutron_lib import constants
18from neutron_lib import context as nctx 18from neutron_lib import context as nctx
19from neutron_lib.plugins import directory 19from neutron_lib.plugins import directory
20from oslo_db import exception as db_exc
20from oslo_utils import uuidutils 21from oslo_utils import uuidutils
22from sqlalchemy.orm import session as se
23from webob import exc
21 24
25from neutron.db import api as db_api
22from neutron.db import models_v2 26from neutron.db import models_v2
23from neutron.plugins.ml2 import config 27from neutron.plugins.ml2 import config
24from neutron.tests.unit.plugins.ml2 import test_plugin 28from neutron.tests.unit.plugins.ml2 import test_plugin
@@ -83,6 +87,55 @@ class TestRevisionPlugin(test_plugin.Ml2PluginV2TestCase):
83 new_rev = response['port']['revision_number'] 87 new_rev = response['port']['revision_number']
84 self.assertGreater(new_rev, rev) 88 self.assertGreater(new_rev, rev)
85 89
90 def test_constrained_port_update(self):
91 with self.port() as port:
92 rev = port['port']['revision_number']
93 new = {'port': {'name': 'nigiri'}}
94 for val in (rev - 1, rev + 1):
95 # make sure off-by ones are rejected
96 self._update('ports', port['port']['id'], new,
97 headers={'If-Match': 'revision_number=%s' % val},
98 expected_code=exc.HTTPPreconditionFailed.code)
99 after_attempt = self._show('ports', port['port']['id'])
100 self.assertEqual(rev, after_attempt['port']['revision_number'])
101 self.assertEqual(port['port']['name'],
102 after_attempt['port']['name'])
103 # correct revision should work
104 self._update('ports', port['port']['id'], new,
105 headers={'If-Match': 'revision_number=%s' % rev})
106
107 def test_constrained_port_delete(self):
108 with self.port() as port:
109 rev = port['port']['revision_number']
110 for val in (rev - 1, rev + 1):
111 # make sure off-by ones are rejected
112 self._delete('ports', port['port']['id'],
113 headers={'If-Match': 'revision_number=%s' % val},
114 expected_code=exc.HTTPPreconditionFailed.code)
115 # correct revision should work
116 self._delete('ports', port['port']['id'],
117 headers={'If-Match': 'revision_number=%s' % rev})
118
119 def test_constrained_port_update_handles_db_retries(self):
120 # here we ensure all of the constraint handling logic persists
121 # on retriable failures to commit caused by races with another
122 # update
123 with self.port() as port:
124 rev = port['port']['revision_number']
125 new = {'port': {'name': 'nigiri'}}
126
127 def concurrent_increment(s):
128 db_api.sqla_remove(se.Session, 'before_commit',
129 concurrent_increment)
130 # slip in a concurrent update that will bump the revision
131 self._update('ports', port['port']['id'], new)
132 raise db_exc.DBDeadlock()
133 db_api.sqla_listen(se.Session, 'before_commit',
134 concurrent_increment)
135 self._update('ports', port['port']['id'], new,
136 headers={'If-Match': 'revision_number=%s' % rev},
137 expected_code=exc.HTTPPreconditionFailed.code)
138
86 def test_port_ip_update_revises(self): 139 def test_port_ip_update_revises(self):
87 with self.port() as port: 140 with self.port() as port:
88 rev = port['port']['revision_number'] 141 rev = port['port']['revision_number']
diff --git a/neutron/tests/unit/testlib_api.py b/neutron/tests/unit/testlib_api.py
index 7815a62..107e541 100644
--- a/neutron/tests/unit/testlib_api.py
+++ b/neutron/tests/unit/testlib_api.py
@@ -48,7 +48,8 @@ class ExpectedException(testtools.ExpectedException):
48 48
49 49
50def create_request(path, body, content_type, method='GET', 50def create_request(path, body, content_type, method='GET',
51 query_string=None, context=None): 51 query_string=None, context=None, headers=None):
52 headers = headers or {}
52 if query_string: 53 if query_string:
53 url = "%s?%s" % (path, query_string) 54 url = "%s?%s" % (path, query_string)
54 else: 55 else:
@@ -57,6 +58,7 @@ def create_request(path, body, content_type, method='GET',
57 req.method = method 58 req.method = method
58 req.headers = {} 59 req.headers = {}
59 req.headers['Accept'] = content_type 60 req.headers['Accept'] = content_type
61 req.headers.update(headers)
60 if isinstance(body, six.text_type): 62 if isinstance(body, six.text_type):
61 req.body = body.encode() 63 req.body = body.encode()
62 else: 64 else:
diff --git a/releasenotes/notes/conditional_updates-10b9aa66fd144217.yaml b/releasenotes/notes/conditional_updates-10b9aa66fd144217.yaml
new file mode 100644
index 0000000..331850c
--- /dev/null
+++ b/releasenotes/notes/conditional_updates-10b9aa66fd144217.yaml
@@ -0,0 +1,14 @@
1---
2prelude: >
3 The Neutron API now supports conditional updates to resources with the
4 'revision_number' attribute by setting the desired revision number in
5 an HTTP If-Match header. This allows clients to ensure that a resource
6 hasn't been modified since it was retrieved by the client.
7features:
8 - |
9 The Neutron API now supports conditional updates to resources with the
10 'revision_number' attribute by setting the desired revision number in
11 an HTTP If-Match header. This allows clients to ensure that a resource
12 hasn't been modified since it was retrieved by the client. Support for
13 conditional updates on the server can be checked for by looking for the
14 'revision-if-match' extension in the supported extensions.