diff --git a/contrib/extraroute/README.md b/contrib/extraroute/README.md new file mode 100644 index 0000000000..d6f500a252 --- /dev/null +++ b/contrib/extraroute/README.md @@ -0,0 +1,38 @@ +ExtraRoute plugin for OpenStack Heat +==================================== + +This plugin enables using ExtraRoute as a resource in a Heat template. + +This resource allows assigning extra routes to Neutron routers via Heat +templates. + +NOTE: Implementing ExtraRoute in the main heat tree is under discussion in the +heat community. + +This plugin has been implemented in contrib to provide access to the +functionality while the discussion takes place, as some users have an immediate +requirement for it. +It may be moved to the main heat tree in due-course, depending on the outcome +of the community discussion. + +### 1. Install the ExtraRoute plugin in Heat + +NOTE: Heat scans several directories to find plugins. The list of directories +is specified in the configuration file "heat.conf" with the "plugin_dirs" +directive. + +### 2. Restart heat + +Only the process "heat-engine" needs to be restarted to load the newly +installed plugin. + +### 3. Example of ExtraRoute + +"router_extraroute": { + "Type": "OS::Neutron::ExtraRoute", + "Properties": { + "router_id": { "Ref" : "router" }, + "destination": "172.16.0.0/24", + "nexthop": "192.168.0.254" + } +} diff --git a/contrib/extraroute/extraroute/__init__.py b/contrib/extraroute/extraroute/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/contrib/extraroute/extraroute/resources/__init__.py b/contrib/extraroute/extraroute/resources/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/contrib/extraroute/extraroute/resources/extraroute.py b/contrib/extraroute/extraroute/resources/extraroute.py new file mode 100644 index 0000000000..c7f15e3da1 --- /dev/null +++ b/contrib/extraroute/extraroute/resources/extraroute.py @@ -0,0 +1,105 @@ + +# +# 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 heat.common import exception +from heat.engine import clients +from heat.engine.resources.neutron import neutron +from heat.engine import properties + +if clients.neutronclient is not None: + from neutronclient.common.exceptions import NeutronClientException + + +class ExtraRoute(neutron.NeutronResource): + + PROPERTIES = ( + ROUTER_ID, DESTINATION, NEXTHOP, + ) = ( + 'router_id', 'destination', 'nexthop', + ) + + properties_schema = { + ROUTER_ID: properties.Schema( + properties.Schema.STRING, + description=_('The router id.'), + required=True), + DESTINATION: properties.Schema( + properties.Schema.STRING, + description=_('Network in CIDR notation.'), + required=True), + NEXTHOP: properties.Schema( + properties.Schema.STRING, + description=_('Nexthop IP adddress.'), + required=True) + } + + def add_dependencies(self, deps): + super(ExtraRoute, self).add_dependencies(deps) + for resource in self.stack.itervalues(): + # depend on any RouterInterface in this template with the same + # router_id as this router_id + if (resource.has_interface('OS::Neutron::RouterInterface') and + resource.properties['router_id'] == + self.properties['router_id']): + deps += (self, resource) + # depend on any RouterGateway in this template with the same + # router_id as this router_id + elif (resource.has_interface('OS::Neutron::RouterGateway') and + resource.properties['router_id'] == + self.properties['router_id']): + deps += (self, resource) + + def handle_create(self): + router_id = self.properties.get(self.ROUTER_ID) + routes = self.neutron().show_router( + router_id).get('router').get('routes') + if not routes: + routes = [] + new_route = {'destination': self.properties[self.DESTINATION], + 'nexthop': self.properties[self.NEXTHOP]} + if new_route in routes: + msg = _('Route duplicates an existing route.') + raise exception.Error(msg) + routes.append(new_route) + self.neutron().update_router(router_id, {'router': + {'routes': routes}}) + new_route['router_id'] = router_id + self.resource_id_set( + '%(router_id)s:%(destination)s:%(nexthop)s' % new_route) + + def handle_delete(self): + if not self.resource_id: + return + (router_id, destination, nexthop) = self.resource_id.split(':') + try: + routes = self.neutron().show_router( + router_id).get('router').get('routes', []) + try: + routes.remove({'destination': destination, + 'nexthop': nexthop}) + except ValueError: + return + self.neutron().update_router(router_id, {'router': + {'routes': routes}}) + except NeutronClientException as ex: + self._handle_not_found_exception(ex) + + +def resource_mapping(): + if clients.neutronclient is None: + return {} + + return { + 'OS::Neutron::ExtraRoute': ExtraRoute, + } diff --git a/contrib/extraroute/extraroute/tests/__init__.py b/contrib/extraroute/extraroute/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/contrib/extraroute/extraroute/tests/test_extraroute.py b/contrib/extraroute/extraroute/tests/test_extraroute.py new file mode 100644 index 0000000000..130fb4695c --- /dev/null +++ b/contrib/extraroute/extraroute/tests/test_extraroute.py @@ -0,0 +1,152 @@ + +# 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 testtools import skipIf + +from heat.engine import clients +from heat.common import template_format +from heat.engine import resource +from heat.engine import scheduler +from heat.engine.resources.neutron import router +from heat.openstack.common.importutils import try_import +from heat.tests.common import HeatTestCase +from heat.tests import fakes +from heat.tests import utils + +from ..resources import extraroute # noqa + +neutronclient = try_import('neutronclient.v2_0.client') +qe = try_import('neutronclient.common.exceptions') + +neutron_template = ''' +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Description" : "Template to test OS::Neutron::ExtraRoute resources", + "Parameters" : {}, + "Resources" : { + "router": { + "Type": "OS::Neutron::Router" + }, + "extraroute1": { + "Type": "OS::Neutron::ExtraRoute", + "Properties": { + "router_id": { "Ref" : "router" }, + "destination" : "192.168.0.0/24", + "nexthop": "1.1.1.1" + } + }, + "extraroute2": { + "Type": "OS::Neutron::ExtraRoute", + "Properties": { + "router_id": { "Ref" : "router" }, + "destination" : "192.168.255.0/24", + "nexthop": "1.1.1.1" + } + } + } +} +''' + + +@skipIf(neutronclient is None, 'neutronclient unavailable') +class NeutronExtraRouteTest(HeatTestCase): + @skipIf(router.neutronV20 is None, "Missing Neutron v2_0") + def setUp(self): + super(NeutronExtraRouteTest, self).setUp() + self.m.StubOutWithMock(neutronclient.Client, 'show_router') + self.m.StubOutWithMock(neutronclient.Client, 'update_router') + self.m.StubOutWithMock(clients.OpenStackClients, 'keystone') + + utils.setup_dummy_db() + + resource._register_class("OS::Neutron::ExtraRoute", + extraroute.ExtraRoute) + + def create_extraroute(self, t, stack, resource_name, properties={}): + t['Resources'][resource_name]['Properties'] = properties + rsrc = extraroute.ExtraRoute( + resource_name, + t['Resources'][resource_name], + stack) + scheduler.TaskRunner(rsrc.create)() + self.assertEqual((rsrc.CREATE, rsrc.COMPLETE), rsrc.state) + return rsrc + + def test_extraroute(self): + clients.OpenStackClients.keystone().AndReturn( + fakes.FakeKeystoneClient()) + # add first route + neutronclient.Client.show_router( + '3e46229d-8fce-4733-819a-b5fe630550f8')\ + .AndReturn({'router': {'routes': []}}) + neutronclient.Client.update_router( + '3e46229d-8fce-4733-819a-b5fe630550f8', + {"router": { + "routes": [ + {"destination": "192.168.0.0/24", "nexthop": "1.1.1.1"}, + ] + }}).AndReturn(None) + # add second route + neutronclient.Client.show_router( + '3e46229d-8fce-4733-819a-b5fe630550f8')\ + .AndReturn({'router': {'routes': [{"destination": "192.168.0.0/24", + "nexthop": "1.1.1.1"}]}}) + neutronclient.Client.update_router( + '3e46229d-8fce-4733-819a-b5fe630550f8', + {"router": { + "routes": [ + {"destination": "192.168.0.0/24", "nexthop": "1.1.1.1"}, + {"destination": "192.168.255.0/24", "nexthop": "1.1.1.1"} + ] + }}).AndReturn(None) + # first delete + neutronclient.Client.show_router( + '3e46229d-8fce-4733-819a-b5fe630550f8')\ + .AndReturn({'router': + {'routes': [{"destination": "192.168.0.0/24", + "nexthop": "1.1.1.1"}, + {"destination": "192.168.255.0/24", + "nexthop": "1.1.1.1"}]}}) + neutronclient.Client.update_router( + '3e46229d-8fce-4733-819a-b5fe630550f8', + {"router": { + "routes": [ + {"destination": "192.168.255.0/24", "nexthop": "1.1.1.1"} + ] + }}).AndReturn(None) + # second delete + neutronclient.Client.show_router( + '3e46229d-8fce-4733-819a-b5fe630550f8')\ + .AndReturn({'router': + {'routes': [{"destination": "192.168.255.0/24", + "nexthop": "1.1.1.1"}]}}) + self.m.ReplayAll() + t = template_format.parse(neutron_template) + stack = utils.parse_stack(t) + + rsrc1 = self.create_extraroute( + t, stack, 'extraroute1', properties={ + 'router_id': '3e46229d-8fce-4733-819a-b5fe630550f8', + 'destination': '192.168.0.0/24', + 'nexthop': '1.1.1.1'}) + + self.create_extraroute( + t, stack, 'extraroute2', properties={ + 'router_id': '3e46229d-8fce-4733-819a-b5fe630550f8', + 'destination': '192.168.255.0/24', + 'nexthop': '1.1.1.1'}) + + scheduler.TaskRunner(rsrc1.delete)() + rsrc1.state_set(rsrc1.CREATE, rsrc1.COMPLETE, 'to delete again') + scheduler.TaskRunner(rsrc1.delete)() + self.m.VerifyAll()