Merge "New resource OS::Neutron::ExtraRouteSet"
This commit is contained in:
commit
a7b554cd80
236
heat/engine/resources/openstack/neutron/extrarouteset.py
Normal file
236
heat/engine/resources/openstack/neutron/extrarouteset.py
Normal file
@ -0,0 +1,236 @@
|
||||
# Copyright 2019 Ericsson Software Technology
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||
# not use this file except in compliance with the License. You may obtain
|
||||
# a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from operator import itemgetter
|
||||
import six
|
||||
|
||||
from oslo_log import log as logging
|
||||
|
||||
from heat.common import exception
|
||||
from heat.common.i18n import _
|
||||
from heat.engine import constraints
|
||||
from heat.engine import properties
|
||||
from heat.engine.resources.openstack.neutron import neutron
|
||||
from heat.engine.resources.openstack.neutron import router
|
||||
from heat.engine import support
|
||||
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ExtraRouteSet(neutron.NeutronResource):
|
||||
"""Resource for specifying extra routes for a Neutron router.
|
||||
|
||||
Requires Neutron ``extraroute-atomic`` extension to be enabled::
|
||||
|
||||
$ openstack extension show extraroute-atomic
|
||||
|
||||
An extra route is a static routing table entry that is added beyond
|
||||
the routes managed implicitly by router interfaces and router gateways.
|
||||
|
||||
The ``destination`` of an extra route is any IP network in /CIDR notation.
|
||||
The ``nexthop`` of an extra route is an IP in a subnet that is directly
|
||||
connected to the router.
|
||||
|
||||
In a single OS::Neutron::ExtraRouteSet resource you can specify a
|
||||
set of extra routes (represented as a list) on the same virtual
|
||||
router. As an improvement over the (never formally supported)
|
||||
OS::Neutron::ExtraRoute resource this resource plugin uses a Neutron
|
||||
API extension (``extraroute-atomic``) that is not prone to race
|
||||
conditions when used to manage multiple extra routes of the same
|
||||
router. It is safe to manage multiple extra routes of the same router
|
||||
from multiple stacks.
|
||||
|
||||
On the other hand use of the same route on the same router is not safe
|
||||
from multiple stacks (or between Heat and non-Heat managed Neutron extra
|
||||
routes).
|
||||
"""
|
||||
|
||||
support_status = support.SupportStatus(version='14.0.0')
|
||||
|
||||
required_service_extension = 'extraroute-atomic'
|
||||
|
||||
PROPERTIES = (
|
||||
ROUTER, ROUTES,
|
||||
) = (
|
||||
'router', 'routes',
|
||||
)
|
||||
|
||||
_ROUTE_KEYS = (
|
||||
DESTINATION, NEXTHOP,
|
||||
) = (
|
||||
'destination', 'nexthop',
|
||||
)
|
||||
|
||||
properties_schema = {
|
||||
ROUTER: properties.Schema(
|
||||
properties.Schema.STRING,
|
||||
description=_('The router id.'),
|
||||
required=True,
|
||||
constraints=[
|
||||
constraints.CustomConstraint('neutron.router')
|
||||
],
|
||||
),
|
||||
ROUTES: properties.Schema(
|
||||
properties.Schema.LIST,
|
||||
_('A set of route dictionaries for the router.'),
|
||||
schema=properties.Schema(
|
||||
properties.Schema.MAP,
|
||||
schema={
|
||||
DESTINATION: properties.Schema(
|
||||
properties.Schema.STRING,
|
||||
_('The destination network in CIDR notation.'),
|
||||
required=True,
|
||||
constraints=[
|
||||
constraints.CustomConstraint('net_cidr')
|
||||
]
|
||||
),
|
||||
NEXTHOP: properties.Schema(
|
||||
properties.Schema.STRING,
|
||||
_('The next hop for the destination.'),
|
||||
required=True,
|
||||
constraints=[
|
||||
constraints.CustomConstraint('ip_addr')
|
||||
]
|
||||
),
|
||||
},
|
||||
),
|
||||
default=[],
|
||||
update_allowed=True,
|
||||
),
|
||||
}
|
||||
|
||||
def add_dependencies(self, deps):
|
||||
super(ExtraRouteSet, self).add_dependencies(deps)
|
||||
for resource in six.itervalues(self.stack):
|
||||
# depend on any RouterInterface in this template with the same
|
||||
# router as this router
|
||||
if resource.has_interface('OS::Neutron::RouterInterface'):
|
||||
try:
|
||||
router_id = self.properties[self.ROUTER]
|
||||
dep_router_id = resource.properties.get(
|
||||
router.RouterInterface.ROUTER)
|
||||
except (ValueError, TypeError):
|
||||
# Properties errors will be caught later in validation,
|
||||
# where we can report them in their proper context.
|
||||
continue
|
||||
if dep_router_id == router_id:
|
||||
deps += (self, resource)
|
||||
|
||||
def handle_create(self):
|
||||
router = self.properties[self.ROUTER]
|
||||
routes = self.properties[self.ROUTES]
|
||||
|
||||
_raise_if_duplicate(self.client().show_router(router), routes)
|
||||
|
||||
self.client().add_extra_routes_to_router(
|
||||
router, {'router': {'routes': routes}})
|
||||
|
||||
# A set of extra routes does not have a physical ID, so all
|
||||
# we can do is to set the resource ID to something at least
|
||||
# informative, that is the router's ID.
|
||||
self.resource_id_set(router)
|
||||
|
||||
def handle_delete(self):
|
||||
if not self.resource_id:
|
||||
return
|
||||
with self.client_plugin().ignore_not_found:
|
||||
self.client().remove_extra_routes_from_router(
|
||||
self.properties[self.ROUTER],
|
||||
{'router': {'routes': self.properties[self.ROUTES]}})
|
||||
|
||||
def handle_update(self, json_snippet, tmpl_diff, prop_diff):
|
||||
"""Handle updates correctly.
|
||||
|
||||
Implementing handle_update() here is not just an optimization but a
|
||||
must, because the default create/delete behavior would delete the
|
||||
unchanged part of the extra route set.
|
||||
"""
|
||||
|
||||
# Ignore the shallow diff done in prop_diff.
|
||||
if self.ROUTES in prop_diff:
|
||||
del prop_diff[self.ROUTES]
|
||||
|
||||
# Do a deep diff instead.
|
||||
old = self.properties[self.ROUTES] or []
|
||||
new = json_snippet.properties(
|
||||
self.properties_schema)[self.ROUTES] or []
|
||||
|
||||
add = _set_to_routes(_routes_to_set(new) - _routes_to_set(old))
|
||||
remove = _set_to_routes(_routes_to_set(old) - _routes_to_set(new))
|
||||
|
||||
router = self.properties[self.ROUTER]
|
||||
|
||||
_raise_if_duplicate(self.client().show_router(router), add)
|
||||
|
||||
# Neither the remove-add nor the add-remove order is perfect.
|
||||
# Likely both will produce transient packet loss.
|
||||
# The remove-add order seems to be conceptually simpler,
|
||||
# never producing unexpected routing tables.
|
||||
self.client().remove_extra_routes_from_router(
|
||||
router, {'router': {'routes': remove}})
|
||||
self.client().add_extra_routes_to_router(
|
||||
router, {'router': {'routes': add}})
|
||||
|
||||
|
||||
def _routes_to_set(route_list):
|
||||
"""Convert routes to a set that can be diffed.
|
||||
|
||||
Convert the in-API/in-template routes format to another data type that
|
||||
has the same information content but that is hashable, so we can put
|
||||
routes in a set and perform set operations on them.
|
||||
"""
|
||||
return set(frozenset(r.items()) for r in route_list)
|
||||
|
||||
|
||||
def _set_to_routes(route_set):
|
||||
"""The reverse of _routes_to_set.
|
||||
|
||||
_set_to_routes(_routes_to_set(routes)) == routes
|
||||
"""
|
||||
return [dict(r) for r in route_set]
|
||||
|
||||
|
||||
def _generate_name(router, routes):
|
||||
return ','.join(
|
||||
['%s' % router] +
|
||||
['%(destination)s=%(nexthop)s' % r for r in sorted(
|
||||
# sort by destination as primary key and
|
||||
# by nexthop as secondary key
|
||||
routes, key=itemgetter('destination', 'nexthop'))])
|
||||
|
||||
|
||||
def _raise_if_duplicate(router_existing, routes_to_add):
|
||||
"""Detect trying to add duplicate routes in create/update
|
||||
|
||||
Take the response of show_router() for an existing router and a list of
|
||||
routes to add and raise PhysicalResourceExists if we try to add a route
|
||||
already existing on the router. Otherwise do not raise and return None.
|
||||
|
||||
You cannot use this to detect duplicate routes atomically while adding
|
||||
a route so when you use this you'll inevitably create race conditions.
|
||||
"""
|
||||
routes_existing = _routes_to_set(
|
||||
router_existing['router']['routes'])
|
||||
for route in _routes_to_set(routes_to_add):
|
||||
if route in routes_existing:
|
||||
original = _set_to_routes(set([route]))
|
||||
name = _generate_name(router, original)
|
||||
raise exception.PhysicalResourceExists(name=name)
|
||||
|
||||
|
||||
def resource_mapping():
|
||||
return {
|
||||
'OS::Neutron::ExtraRouteSet': ExtraRouteSet,
|
||||
}
|
237
heat/tests/openstack/neutron/test_neutron_extrarouteset.py
Normal file
237
heat/tests/openstack/neutron/test_neutron_extrarouteset.py
Normal file
@ -0,0 +1,237 @@
|
||||
# Copyright 2019 Ericsson Software Technology
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||
# not use this file except in compliance with the License. You may obtain
|
||||
# a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
import copy
|
||||
|
||||
from oslo_log import log as logging
|
||||
|
||||
from heat.common import exception
|
||||
from heat.common import template_format
|
||||
from heat.engine.clients.os import neutron
|
||||
from heat.engine.resources.openstack.neutron import extrarouteset
|
||||
from heat.engine import scheduler
|
||||
from heat.tests import common
|
||||
from heat.tests import utils
|
||||
from neutronclient.common import exceptions as ncex
|
||||
from neutronclient.neutron import v2_0 as neutronV20
|
||||
from neutronclient.v2_0 import client as neutronclient
|
||||
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
template = '''
|
||||
heat_template_version: rocky
|
||||
description: Test create OS::Neutron::ExtraRouteSet
|
||||
resources:
|
||||
extrarouteset0:
|
||||
type: OS::Neutron::ExtraRouteSet
|
||||
properties:
|
||||
router: 88ce38c4-be8e-11e9-a0a5-5f64570eeec8
|
||||
routes:
|
||||
- destination: 10.0.1.0/24
|
||||
nexthop: 10.0.0.11
|
||||
- destination: 10.0.2.0/24
|
||||
nexthop: 10.0.0.12
|
||||
'''
|
||||
|
||||
|
||||
class NeutronExtraRouteSetTest(common.HeatTestCase):
|
||||
|
||||
def setUp(self):
|
||||
super(NeutronExtraRouteSetTest, self).setUp()
|
||||
|
||||
self.patchobject(
|
||||
neutron.NeutronClientPlugin, 'has_extension', return_value=True)
|
||||
|
||||
self.add_extra_routes_mock = self.patchobject(
|
||||
neutronclient.Client, 'add_extra_routes_to_router')
|
||||
self.add_extra_routes_mock.return_value = {
|
||||
'router': {
|
||||
'id': '85b91046-be84-11e9-b518-2714ef1d76c3',
|
||||
'routes': [
|
||||
{'destination': '10.0.1.0/24', 'nexthop': '10.0.0.11'},
|
||||
{'destination': '10.0.2.0/24', 'nexthop': '10.0.0.12'},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
self.remove_extra_routes_mock = self.patchobject(
|
||||
neutronclient.Client, 'remove_extra_routes_from_router')
|
||||
self.remove_extra_routes_mock.return_value = {
|
||||
'router': {
|
||||
'id': '85b91046-be84-11e9-b518-2714ef1d76c3',
|
||||
'routes': [
|
||||
{'destination': '10.0.1.0/24', 'nexthop': '10.0.0.11'},
|
||||
{'destination': '10.0.2.0/24', 'nexthop': '10.0.0.12'},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
self.show_router_mock = self.patchobject(
|
||||
neutronclient.Client, 'show_router')
|
||||
self.show_router_mock.return_value = {
|
||||
'router': {
|
||||
'id': '85b91046-be84-11e9-b518-2714ef1d76c3',
|
||||
'routes': [],
|
||||
}
|
||||
}
|
||||
|
||||
def find_resourceid_by_name_or_id(
|
||||
_client, _resource, name_or_id, **_kwargs):
|
||||
return name_or_id
|
||||
|
||||
self.find_resource_mock = self.patchobject(
|
||||
neutronV20, 'find_resourceid_by_name_or_id')
|
||||
self.find_resource_mock.side_effect = find_resourceid_by_name_or_id
|
||||
|
||||
def test_routes_to_set_to_routes(self):
|
||||
routes = [{'destination': '10.0.1.0/24', 'nexthop': '10.0.0.11'}]
|
||||
self.assertEqual(
|
||||
routes,
|
||||
extrarouteset._set_to_routes(extrarouteset._routes_to_set(routes))
|
||||
)
|
||||
|
||||
def test_diff_routes(self):
|
||||
old = [
|
||||
{'destination': '10.0.1.0/24', 'nexthop': '10.0.0.11'},
|
||||
{'destination': '10.0.2.0/24', 'nexthop': '10.0.0.12'},
|
||||
]
|
||||
new = [
|
||||
{'destination': '10.0.1.0/24', 'nexthop': '10.0.0.11'},
|
||||
{'destination': '10.0.3.0/24', 'nexthop': '10.0.0.13'},
|
||||
]
|
||||
|
||||
add = extrarouteset._set_to_routes(
|
||||
extrarouteset._routes_to_set(new) -
|
||||
extrarouteset._routes_to_set(old))
|
||||
remove = extrarouteset._set_to_routes(
|
||||
extrarouteset._routes_to_set(old) -
|
||||
extrarouteset._routes_to_set(new))
|
||||
|
||||
self.assertEqual(
|
||||
[{'destination': '10.0.3.0/24', 'nexthop': '10.0.0.13'}], add)
|
||||
self.assertEqual(
|
||||
[{'destination': '10.0.2.0/24', 'nexthop': '10.0.0.12'}], remove)
|
||||
|
||||
def test__raise_if_duplicate_positive(self):
|
||||
self.assertRaises(
|
||||
exception.PhysicalResourceExists,
|
||||
extrarouteset._raise_if_duplicate,
|
||||
{'router': {'routes': [
|
||||
{'destination': 'dst1', 'nexthop': 'hop1'},
|
||||
]}},
|
||||
[{'destination': 'dst1', 'nexthop': 'hop1'}],
|
||||
)
|
||||
|
||||
def test__raise_if_duplicate_negative(self):
|
||||
try:
|
||||
extrarouteset._raise_if_duplicate(
|
||||
{'router': {'routes': [
|
||||
{'destination': 'dst1', 'nexthop': 'hop1'},
|
||||
]}},
|
||||
[{'destination': 'dst2', 'nexthop': 'hop2'}],
|
||||
)
|
||||
except exception.PhysicalResourceExists:
|
||||
self.fail('Unexpected exception in detecting duplicate routes')
|
||||
|
||||
def test_create(self):
|
||||
t = template_format.parse(template)
|
||||
stack = utils.parse_stack(t)
|
||||
|
||||
extra_routes = stack['extrarouteset0']
|
||||
scheduler.TaskRunner(extra_routes.create)()
|
||||
|
||||
self.assertEqual(
|
||||
(extra_routes.CREATE, extra_routes.COMPLETE), extra_routes.state)
|
||||
self.add_extra_routes_mock.assert_called_once_with(
|
||||
'88ce38c4-be8e-11e9-a0a5-5f64570eeec8',
|
||||
{'router': {
|
||||
'routes': [
|
||||
{'destination': '10.0.1.0/24', 'nexthop': '10.0.0.11'},
|
||||
{'destination': '10.0.2.0/24', 'nexthop': '10.0.0.12'},
|
||||
]}})
|
||||
|
||||
def test_delete_proper(self):
|
||||
t = template_format.parse(template)
|
||||
stack = utils.parse_stack(t)
|
||||
|
||||
extra_routes = stack['extrarouteset0']
|
||||
scheduler.TaskRunner(extra_routes.create)()
|
||||
scheduler.TaskRunner(extra_routes.delete)()
|
||||
|
||||
self.assertEqual(
|
||||
(extra_routes.DELETE, extra_routes.COMPLETE), extra_routes.state)
|
||||
self.remove_extra_routes_mock.assert_called_once_with(
|
||||
'88ce38c4-be8e-11e9-a0a5-5f64570eeec8',
|
||||
{'router': {
|
||||
'routes': [
|
||||
{'destination': '10.0.1.0/24', 'nexthop': '10.0.0.11'},
|
||||
{'destination': '10.0.2.0/24', 'nexthop': '10.0.0.12'},
|
||||
]}})
|
||||
|
||||
def test_delete_router_already_gone(self):
|
||||
t = template_format.parse(template)
|
||||
stack = utils.parse_stack(t)
|
||||
|
||||
self.remove_extra_routes_mock.side_effect = (
|
||||
ncex.NeutronClientException(status_code=404))
|
||||
|
||||
extra_routes = stack['extrarouteset0']
|
||||
scheduler.TaskRunner(extra_routes.create)()
|
||||
scheduler.TaskRunner(extra_routes.delete)()
|
||||
|
||||
self.assertEqual(
|
||||
(extra_routes.DELETE, extra_routes.COMPLETE), extra_routes.state)
|
||||
self.remove_extra_routes_mock.assert_called_once_with(
|
||||
'88ce38c4-be8e-11e9-a0a5-5f64570eeec8',
|
||||
{'router': {
|
||||
'routes': [
|
||||
{'destination': '10.0.1.0/24', 'nexthop': '10.0.0.11'},
|
||||
{'destination': '10.0.2.0/24', 'nexthop': '10.0.0.12'},
|
||||
]}})
|
||||
|
||||
def test_update(self):
|
||||
t = template_format.parse(template)
|
||||
stack = utils.parse_stack(t)
|
||||
|
||||
extra_routes = stack['extrarouteset0']
|
||||
scheduler.TaskRunner(extra_routes.create)()
|
||||
self.assertEqual(
|
||||
(extra_routes.CREATE, extra_routes.COMPLETE), extra_routes.state)
|
||||
|
||||
self.add_extra_routes_mock.reset_mock()
|
||||
|
||||
rsrc_defn = stack.defn.resource_definition('extrarouteset0')
|
||||
|
||||
props = copy.deepcopy(t['resources']['extrarouteset0']['properties'])
|
||||
props['routes'][1] = {
|
||||
'destination': '10.0.3.0/24', 'nexthop': '10.0.0.13'}
|
||||
rsrc_defn = rsrc_defn.freeze(properties=props)
|
||||
|
||||
scheduler.TaskRunner(extra_routes.update, rsrc_defn)()
|
||||
self.assertEqual(
|
||||
(extra_routes.UPDATE, extra_routes.COMPLETE), extra_routes.state)
|
||||
|
||||
self.remove_extra_routes_mock.assert_called_once_with(
|
||||
'88ce38c4-be8e-11e9-a0a5-5f64570eeec8',
|
||||
{'router': {
|
||||
'routes': [
|
||||
{'destination': '10.0.2.0/24', 'nexthop': '10.0.0.12'},
|
||||
]}})
|
||||
self.add_extra_routes_mock.assert_called_once_with(
|
||||
'88ce38c4-be8e-11e9-a0a5-5f64570eeec8',
|
||||
{'router': {
|
||||
'routes': [
|
||||
{'destination': '10.0.3.0/24', 'nexthop': '10.0.0.13'},
|
||||
]}})
|
@ -116,7 +116,7 @@ python-manilaclient==1.16.0
|
||||
python-mimeparse==1.6.0
|
||||
python-mistralclient==3.1.0
|
||||
python-monascaclient==1.12.0
|
||||
python-neutronclient==6.7.0
|
||||
python-neutronclient==6.14.0
|
||||
python-novaclient==9.1.0
|
||||
python-octaviaclient==1.8.0
|
||||
python-openstackclient==3.12.0
|
||||
|
@ -0,0 +1,10 @@
|
||||
---
|
||||
features:
|
||||
- |
|
||||
New resource ``OS::Neutron::ExtraRouteSet`` is added to manage extra
|
||||
routes of a Neutron router.
|
||||
deprecations:
|
||||
- |
|
||||
Unsupported contrib resource ``OS::Neutron::ExtraRoute`` is deprecated
|
||||
in favor of ``OS::Neutron::ExtraRouteSet`` on all OpenStack clouds where
|
||||
Neutron extension ``extraroute-atomic`` is available.
|
@ -43,7 +43,7 @@ python-magnumclient>=2.3.0 # Apache-2.0
|
||||
python-manilaclient>=1.16.0 # Apache-2.0
|
||||
python-mistralclient!=3.2.0,>=3.1.0 # Apache-2.0
|
||||
python-monascaclient>=1.12.0 # Apache-2.0
|
||||
python-neutronclient>=6.7.0 # Apache-2.0
|
||||
python-neutronclient>=6.14.0 # Apache-2.0
|
||||
python-novaclient>=9.1.0 # Apache-2.0
|
||||
python-octaviaclient>=1.8.0 # Apache-2.0
|
||||
python-openstackclient>=3.12.0 # Apache-2.0
|
||||
|
Loading…
Reference in New Issue
Block a user