From 9fdc413eecb8b6a6cb7191508581699e01368457 Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Fri, 13 Jun 2014 12:03:41 -0400 Subject: [PATCH] Properly support neutron subnet `host_routes` on the router appliance. --- akanda/router/api/server.py | 4 ++ akanda/router/api/v1/system.py | 3 +- akanda/router/drivers/route.py | 61 ++++++++++++++++ akanda/router/manager.py | 7 +- akanda/router/models.py | 2 +- setup.py | 1 + test/unit/drivers/test_route.py | 122 ++++++++++++++++++++++++++++++++ 7 files changed, 195 insertions(+), 5 deletions(-) diff --git a/akanda/router/api/server.py b/akanda/router/api/server.py index 9a44a6b..891c953 100644 --- a/akanda/router/api/server.py +++ b/akanda/router/api/server.py @@ -18,6 +18,7 @@ """Set up the API server application instance """ import flask +from flask.ext import shelve from akanda.router.api import v1 from akanda.router.debug import handle_traceback @@ -30,6 +31,9 @@ app.register_blueprint(v1.firewall.blueprint) app.register_blueprint(v1.status.blueprint) app.register_error_handler(500, handle_traceback) +app.config['SHELVE_FILENAME'] = '/tmp/akanda-state' +shelve.init_app(app) + @app.before_request def attach_config(): diff --git a/akanda/router/api/v1/system.py b/akanda/router/api/v1/system.py index 8a23919..101c694 100644 --- a/akanda/router/api/v1/system.py +++ b/akanda/router/api/v1/system.py @@ -20,6 +20,7 @@ Blueprint for the "system" portion of the version 1 of the API. """ from flask import Response from flask import abort, request +from flask.ext import shelve from akanda.router import models from akanda.router import utils @@ -73,5 +74,5 @@ def put_configuration(): 'The config failed to validate.\n' + '\n'.join(errors), status=422) - manager.update_config(config_candidate) + manager.update_config(config_candidate, shelve) return dict(configuration=manager.config) diff --git a/akanda/router/drivers/route.py b/akanda/router/drivers/route.py index f1771ae..42b9ed3 100644 --- a/akanda/router/drivers/route.py +++ b/akanda/router/drivers/route.py @@ -61,6 +61,48 @@ class RouteManager(base.Manager): self._set_default_gateway(subnet.gateway_ip) gw_set[subnet.gateway_ip.version] = True + def update_host_routes(self, config, db): + for net in config.networks: + + # For each subnet... + for subnet in net.subnets: + cidr = str(subnet.cidr) + + # ...if there are no host_routes configured, unset all of the + # previous host_routes for this subnet, and move on to the next + # subnet + if not subnet.host_routes: + self._empty_host_routes(cidr, db) + continue + + # determine the set of previously written routes for this cidr + if cidr not in db: + db[cidr] = set() + + current = db[cidr] + + # build a set of new routes for this cidr + latest = set() + for r in subnet.host_routes: + latest.add((r.destination, r.next_hop)) + + # If the set of previously written routes contains routes that + # aren't defined in the new config, run commands to delete them + for x in current - latest: + if self._alter_route('delete', *x): + current.remove(x) + + # If the new config contains routes that aren't defined in the + # set of previously written routes, run commands to add them + for x in latest - current: + if self._alter_route('add', *x): + current.add(x) + + # This assignment *is* necessary - Python's `shelve` + # implementation isn't smart enough to capture the changes to + # the reference above, so this setitem call triggers a DB sync + db[cidr] = current + def _get_default_gateway(self, version): current = None try: @@ -93,3 +135,22 @@ class RouteManager(base.Manager): return self.sudo('change', version, 'default', desired) # Nothing to do return '' + + def _empty_host_routes(self, cidr, db): + if cidr in db: + for x in db[cidr]: + self._alter_route('delete', *x) + del db[cidr] + + def _alter_route(self, action, destination, next_hop): + version = '-inet' + if destination.version == 6: + version += '6' + try: + return self.sudo(action, version, str(destination), str(next_hop)) + except RuntimeError as e: + # Since these are user-supplied custom routes, it's very possible + # that adding/removing them will fail. A failure to apply one of + # these custom rules, however, should *not* cause an overall router + # failure. + LOG.warn('Route could not be %sed: %s' % (action, unicode(e))) diff --git a/akanda/router/manager.py b/akanda/router/manager.py index e552d61..f8d2f2d 100644 --- a/akanda/router/manager.py +++ b/akanda/router/manager.py @@ -43,7 +43,7 @@ class Manager(object): return self._config - def update_config(self, config): + def update_config(self, config, db): self._config = config self.update_interfaces() @@ -51,7 +51,7 @@ class Manager(object): self.update_metadata() self.update_bgp_and_radv() self.update_pf() - self.update_routes() + self.update_routes(db) self.update_arp() # TODO(mark): update_vpn @@ -83,9 +83,10 @@ class Manager(object): mgr = pf.PFManager() mgr.update_conf(rule_data) - def update_routes(self): + def update_routes(self, db): mgr = route.RouteManager() mgr.update_default(self.config) + mgr.update_host_routes(self.config, db.get_shelve('c')) def update_arp(self): mgr = arp.ARPManager() diff --git a/akanda/router/models.py b/akanda/router/models.py index 3ead6a9..30c40e3 100644 --- a/akanda/router/models.py +++ b/akanda/router/models.py @@ -428,7 +428,7 @@ class Subnet(ModelBase): @classmethod def from_dict(cls, d): - host_routes = [StaticRoute(r['destination'], r['next_hop']) + host_routes = [StaticRoute(r['destination'], r['nexthop']) for r in d.get('host_routes', [])] return cls( d['cidr'], diff --git a/setup.py b/setup.py index 74110e8..dfe7b31 100644 --- a/setup.py +++ b/setup.py @@ -28,6 +28,7 @@ setup( license='BSD', install_requires=[ 'flask>=0.9', + 'flask-shelve>=0.1.1', 'gunicorn>=0.14.6,<19', 'netaddr>=0.7.7', 'eventlet>=0.9.17', diff --git a/test/unit/drivers/test_route.py b/test/unit/drivers/test_route.py index b4e759d..92cde35 100644 --- a/test/unit/drivers/test_route.py +++ b/test/unit/drivers/test_route.py @@ -18,6 +18,8 @@ import mock import unittest2 +import netaddr + from akanda.router import models from akanda.router.drivers import route @@ -260,3 +262,123 @@ sockaddrs: net = c.networks[0] snet = net.subnets[0] set.assert_called_once_with(snet.gateway_ip) + + @mock.patch.object(route.RouteManager, '_set_default_gateway', + lambda *a, **kw: None) + def test_custom_host_routes(self): + subnet = dict( + cidr='192.168.89.0/24', + gateway_ip='192.168.89.1', + dhcp_enabled=True, + dns_nameservers=[], + host_routes=[{ + 'destination': '192.240.128.0/20', + 'nexthop': '192.168.89.2' + }] + ) + network = dict( + network_id='netid', + interface=dict(ifname='ge0', addresses=['fe80::2']), + subnets=[subnet] + ) + c = models.Configuration({'networks': [network]}) + + db = {} + with mock.patch.object(self.mgr, 'sudo') as sudo: + + # ...so let's add one! + self.mgr.update_host_routes(c, db) + sudo.assert_called_once_with( + 'add', '-inet', '192.240.128.0/20', '192.168.89.2' + ) + + # db[subnet.cidr] should contain the above route + expected = set() + expected.add(( + netaddr.IPNetwork('192.240.138.0/20'), + netaddr.IPAddress('192.168.89.2') + )) + self.assertEqual(len(db), 1) + self.assertEqual( + db[subnet['cidr']] - expected, + set() + ) + + # Empty the host_routes list + sudo.reset_mock() + subnet['host_routes'] = [] + c = models.Configuration({'networks': [network]}) + self.mgr.update_host_routes(c, db) + sudo.assert_called_once_with( + 'delete', '-inet', '192.240.128.0/20', '192.168.89.2' + ) + self.assertEqual(len(db), 0) + + # ...this time, let's add multiple routes and ensure they're added + sudo.reset_mock() + subnet['host_routes'] = [{ + 'destination': '192.240.128.0/20', + 'nexthop': '192.168.89.2' + }, { + 'destination': '192.220.128.0/20', + 'nexthop': '192.168.89.3' + }] + c = models.Configuration({'networks': [network]}) + self.mgr.update_host_routes(c, db) + self.assertEqual(sudo.call_args_list, [ + mock.call('add', '-inet', '192.240.128.0/20', '192.168.89.2'), + mock.call('add', '-inet', '192.220.128.0/20', '192.168.89.3'), + ]) + + # ...let's remove one and add another... + sudo.reset_mock() + subnet['host_routes'] = [{ + 'destination': '192.240.128.0/20', + 'nexthop': '192.168.89.2' + }, { + 'destination': '192.185.128.0/20', + 'nexthop': '192.168.89.4' + }] + c = models.Configuration({'networks': [network]}) + self.mgr.update_host_routes(c, db) + self.assertEqual(sudo.call_args_list, [ + mock.call('delete', '-inet', '192.220.128.0/20', + '192.168.89.3'), + mock.call('add', '-inet', '192.185.128.0/20', '192.168.89.4') + ]) + + # ...let's add another subnet... + self.assertEqual(len(db), 1) + sudo.reset_mock() + network['subnets'].append(dict( + cidr='192.168.90.0/24', + gateway_ip='192.168.90.1', + dhcp_enabled=True, + dns_nameservers=[], + host_routes=[{ + 'destination': '192.240.128.0/20', + 'nexthop': '192.168.90.1' + }] + )) + c = models.Configuration({'networks': [network]}) + self.mgr.update_host_routes(c, db) + self.assertEqual(sudo.call_args_list, [ + mock.call('add', '-inet', '192.240.128.0/20', '192.168.90.1') + ]) + self.assertEqual(len(db), 2) + + # ...and finally, delete all custom host_routes... + sudo.reset_mock() + network['subnets'][0]['host_routes'] = [] + network['subnets'][1]['host_routes'] = [] + c = models.Configuration({'networks': [network]}) + self.mgr.update_host_routes(c, db) + self.assertEqual(sudo.call_args_list, [ + mock.call('delete', '-inet', '192.185.128.0/20', + '192.168.89.4'), + mock.call('delete', '-inet', '192.240.128.0/20', + '192.168.89.2'), + mock.call('delete', '-inet', '192.240.128.0/20', + '192.168.90.1'), + ]) + self.assertEqual(len(db), 0)