From 433a4c7190732c2e20255c4148904a7c74d8a16a Mon Sep 17 00:00:00 2001 From: Adam Gandelman Date: Mon, 21 Sep 2015 18:16:00 -0700 Subject: [PATCH] Introduces advanced service drivers to akanda-appliance This introduces the ability to create service manager drivers to handle managing advanced services within the akanda-appliance. It splits some common things into a System manager. Existing stuff that is router-specific is moved to a Router manager and we begin implementing LBAAS drivers using Nginx. At the moment, configuration for which drivers are loaded by the appliance code itself is stored in /etc/default/akanda-appliance. This is setup by a DIB_* variable and accessed by the appliance via environment variable. We should improve this later when we need to expose richer configuration to the appliance. We could and should work on the API for this. Currently, our v1 API is entirely router-specific. This adds to that and allows the RUG to attach other advanced service configuratino data to the config object it pushes. If the corresponding service's driver has been enabled in the appliance, it will attempt to find that data and configure the advanced service accordingly. Ideally, longterm we want a v2 API that can reference all services the same. There's a few ugly compat hacks added here to maintain compatability with where the RUG expects certain router resources to be. We can evolve this over time. Partially-implements: blueprint appliance-provisioning-driver Depends-on: Ic19a883f56fb6d65a83b1f4d93b581f9e242d97f Change-Id: I6048789ec15fad1dbc899cbbd82508433cb96d44 --- MANIFEST.in | 1 + akanda/router/api/v1/system.py | 74 ++++- akanda/router/drivers/iptables.py | 0 .../router/drivers/loadbalancer/__init__.py | 37 +++ .../drivers/loadbalancer/nginx.conf.template | 19 ++ akanda/router/drivers/loadbalancer/nginx.py | 75 +++++ akanda/router/manager.py | 203 ++++++++++-- akanda/router/models.py | 299 ++++++++++++++++-- akanda/router/settings.py | 13 + akanda/router/utils.py | 4 +- ansible/main.yml | 2 +- ansible/tasks/akanda.yml | 9 + diskimage-builder/elements/akanda/README.rst | 6 + .../install.d/akanda-source-install/70-akanda | 4 +- .../post-install.d/99-disable-default-nginx | 16 + scripts/etc/init.d/akanda-router-api-server | 2 +- setup.cfg | 10 +- test/unit/api/v1/test_system.py | 189 ++++++++++- test/unit/drivers/test_arp.py | 2 +- test/unit/drivers/test_iptables.py | 8 +- test/unit/drivers/test_route.py | 26 +- test/unit/fakes.py | 138 ++++++++ test/unit/test_models.py | 186 ++++++++++- test/unit/test_utils.py | 1 - 24 files changed, 1209 insertions(+), 115 deletions(-) create mode 100644 MANIFEST.in mode change 100755 => 100644 akanda/router/drivers/iptables.py create mode 100644 akanda/router/drivers/loadbalancer/__init__.py create mode 100644 akanda/router/drivers/loadbalancer/nginx.conf.template create mode 100644 akanda/router/drivers/loadbalancer/nginx.py create mode 100644 akanda/router/settings.py create mode 100755 diskimage-builder/elements/nginx/post-install.d/99-disable-default-nginx create mode 100644 test/unit/fakes.py diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..87fceb3 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1 @@ +include akanda/router/drivers/loadbalancer/nginx.conf.template diff --git a/akanda/router/api/v1/system.py b/akanda/router/api/v1/system.py index 6255194..1139c36 100644 --- a/akanda/router/api/v1/system.py +++ b/akanda/router/api/v1/system.py @@ -24,6 +24,7 @@ from dogpile.cache import make_region from akanda.router import models from akanda.router import utils +from akanda.router import settings from akanda.router.manager import manager blueprint = utils.blueprint_factory(__name__) @@ -32,6 +33,9 @@ blueprint = utils.blueprint_factory(__name__) _cache = None +ADVANCED_SERVICES_KEY = 'services' + + def _get_cache(): global _cache if _cache is None: @@ -51,7 +55,7 @@ def get_interface(ifname): Show interface parameters given an interface name. For example ge1, ge2 for generic ethernet ''' - return dict(interface=manager.get_interface(ifname)) + return dict(interface=manager.router.get_interface(ifname)) @blueprint.route('/interfaces') @@ -60,7 +64,7 @@ def get_interfaces(): ''' Show all interfaces and parameters ''' - return dict(interfaces=manager.get_interfaces()) + return dict(interfaces=manager.router.get_interfaces()) @blueprint.route('/config', methods=['GET']) @@ -77,17 +81,75 @@ def put_configuration(): abort(415) try: - config_candidate = models.Configuration(request.json) + system_config_candidate = models.SystemConfiguration(request.json) except ValueError, e: return Response( - 'The config failed to deserialize.\n' + str(e), + 'The system config failed to deserialize.\n' + str(e), status=422) - errors = config_candidate.validate() + errors = system_config_candidate.validate() if errors: return Response( 'The config failed to validate.\n' + '\n'.join(errors), status=422) - manager.update_config(config_candidate, _get_cache()) + # Config requests to a router appliance will always contain a default ASN, + # so we can key on that for now. Later on we need to move router stuff + # to the extensible list of things the appliance can handle + if request.json.get('asn'): + try: + router_config_candidate = models.RouterConfiguration(request.json) + except ValueError, e: + return Response( + 'The router config failed to deserialize.\n' + str(e), + status=422) + + errors = router_config_candidate.validate() + if errors: + return Response( + 'The config failed to validate.\n' + '\n'.join(errors), + status=422) + else: + router_config_candidate = None + + if router_config_candidate: + advanced_service_configs = [router_config_candidate] + else: + advanced_service_configs = [] + + advanced_services = request.json.get(ADVANCED_SERVICES_KEY, {}) + for svc in advanced_services.keys(): + if svc not in settings.ENABLED_SERVICES: + return Response( + 'This appliance cannot service requested advanced ' + 'service: %s' % svc, status=400) + + for svc in settings.ENABLED_SERVICES: + if not advanced_services.get(svc): + continue + + config_model = models.get_config_model(service=svc) + if not config_model: + continue + + try: + svc_config_candidate = config_model(advanced_services.get(svc)) + except ValueError, e: + return Response( + 'The %s config failed to deserialize.\n' + str(e) % + config_model.service_name, status=422) + + errors = svc_config_candidate.validate() + if errors: + return Response( + 'The %s config failed to validate.\n' + '\n'.join(errors), + config_model.service_name, status=422) + + advanced_service_configs.append(svc_config_candidate) + + manager.update_config( + system_config=system_config_candidate, + service_configs=advanced_service_configs, + cache=_get_cache()) + return dict(configuration=manager.config) diff --git a/akanda/router/drivers/iptables.py b/akanda/router/drivers/iptables.py old mode 100755 new mode 100644 diff --git a/akanda/router/drivers/loadbalancer/__init__.py b/akanda/router/drivers/loadbalancer/__init__.py new file mode 100644 index 0000000..eb1e520 --- /dev/null +++ b/akanda/router/drivers/loadbalancer/__init__.py @@ -0,0 +1,37 @@ +# Copyright (c) 2015 Akanda, Inc. All Rights Reserved. +# +# 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 akanda.router.drivers.loadbalancer import nginx + +# XXX move to config +CONFIGURED_LB_DRIVER = 'nginx' + +AVAILABLE_DRIVERS = { + 'nginx': nginx.NginxLB, + 'nginx+': nginx.NginxPlusLB, + # 'haxproxy': HaProxyLB, +} + + +class InvalidDriverException(Exception): + pass + + +def get_loadbalancer_driver(name): + try: + return AVAILABLE_DRIVERS[name] + except KeyError: + raise InvalidDriverException( + 'Could not find LB driver by name %s' % name) diff --git a/akanda/router/drivers/loadbalancer/nginx.conf.template b/akanda/router/drivers/loadbalancer/nginx.conf.template new file mode 100644 index 0000000..e668702 --- /dev/null +++ b/akanda/router/drivers/loadbalancer/nginx.conf.template @@ -0,0 +1,19 @@ +{%- for listener in loadbalancer.listeners %} +{%- if listener.default_pool and listener.default_pool.members %} + +server { + listen {{ loadbalancer.vip_address }}:{{ listener.protocol_port }}; + location / { + proxy_pass {{ listener.protocol.lower() }}://pool_{{ listener.default_pool.id }}; + } +} + +upstream pool_{{ listener.default_pool.id }} { + {%- for member in listener.default_pool.members: %} + server {{ member.address }}:{{ member.protocol_port }} weight={{ member.weight }}; + {%- endfor %} +} + +{%- endif %} +{%- endfor %} + diff --git a/akanda/router/drivers/loadbalancer/nginx.py b/akanda/router/drivers/loadbalancer/nginx.py new file mode 100644 index 0000000..1e74819 --- /dev/null +++ b/akanda/router/drivers/loadbalancer/nginx.py @@ -0,0 +1,75 @@ +# Copyright (c) 2015 Akanda, Inc. All Rights Reserved. +# +# 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 jinja2 +import os + +from akanda.router.drivers import base +from akanda.router.utils import execute + + +class NginxTemplateNotFound(Exception): + # TODO(adam_g): These should return 50x errors and not logged + # exceptions. + pass + + +class NginxLB(base.Manager): + NAME = 'nginx' + CONFIG_PATH = '/etc/nginx/sites-enabled/' + CONFIG_FILE_TEMPLATE = os.path.join( + os.path.dirname(__file__), 'nginx.conf.template') + INIT = 'nginx' + + def __init__(self, root_helper='sudo'): + """ + Initializes DHCPManager class. + + :type root_helper: str + :param root_helper: System utility used to gain escalate privileges. + """ + super(NginxLB, self).__init__(root_helper) + self._load_template() + + def _load_template(self): + if not os.path.exists(self.CONFIG_FILE_TEMPLATE): + raise NginxTemplateNotFound( + 'NGINX Config template not found @ %s' % + self.CONFIG_FILE_TEMPLATE + ) + self.config_tmpl = jinja2.Template( + open(self.CONFIG_FILE_TEMPLATE).read()) + + def _render_config_template(self, path, config): + self._load_template() + with open(path, 'w') as out: + out.write( + self.config_tmpl.render(loadbalancer=config) + ) + + def restart(self): + execute(['service', self.INIT, 'restart'], self.root_helper) + pass + + def update_config(self, config): + path = os.path.join( + self.CONFIG_PATH, 'ak-loadbalancer-%s.conf' % config.id) + self._render_config_template(path=path, config=config) + self.restart() + + +class NginxPlusLB(NginxLB): + NAME = 'nginxplus' + CONFIG_FILE = '/tmp/nginx_plus.conf' + INIT = 'nginxplus' diff --git a/akanda/router/manager.py b/akanda/router/manager.py index 80eac54..2a1eb9d 100644 --- a/akanda/router/manager.py +++ b/akanda/router/manager.py @@ -19,35 +19,61 @@ import os import re from akanda.router import models +from akanda.router import settings from akanda.router.drivers import (bird, dnsmasq, ip, metadata, - iptables, arp, hostname) + iptables, arp, hostname, loadbalancer) -class Manager(object): +class ServiceManagerBase(object): def __init__(self, state_path='.'): + self._config = None self.state_path = os.path.abspath(state_path) - self.ip_mgr = ip.IPManager() - self.ip_mgr.ensure_mapping() - self._config = models.Configuration() - - def management_address(self, ensure_configuration=False): - return self.ip_mgr.get_management_address(ensure_configuration) @property def config(self): """Make config a read-only property. To update the value, update_config() must called to change the global - state of router. + state of appliance. """ - return self._config def update_config(self, config, cache): - self._config = config + pass + +class SystemManager(ServiceManagerBase): + def __init__(self, state_path='.'): + super(SystemManager, self).__init__(state_path) + self._config = models.SystemConfiguration() + self.ip_mgr = ip.IPManager() + self.ip_mgr.ensure_mapping() + + def update_config(self, config, cache): + self._config = config self.update_hostname() self.update_interfaces() + + def update_hostname(self): + mgr = hostname.HostnameManager() + mgr.update(self._config) + + def update_interfaces(self): + for network in self._config.networks: + self.ip_mgr.disable_duplicate_address_detection(network) + self.ip_mgr.update_interfaces(self._config.interfaces) + + +class RouterManager(ServiceManagerBase): + def __init__(self, state_path='.'): + super(RouterManager, self).__init__(state_path) + self.ip_mgr = ip.IPManager() + self.ip_mgr.ensure_mapping() + + def update_config(self, config, cache): + + self._config = config + self.update_interfaces() self.update_dhcp() self.update_metadata() self.update_bgp_and_radv() @@ -55,30 +81,24 @@ class Manager(object): self.update_routes(cache) self.update_arp() - # TODO(mark): update_vpn - - def update_hostname(self): - mgr = hostname.HostnameManager() - mgr.update(self.config) - def update_interfaces(self): - for network in self.config.networks: + for network in self._config.networks: self.ip_mgr.disable_duplicate_address_detection(network) - self.ip_mgr.update_interfaces(self.config.interfaces) + self.ip_mgr.update_interfaces(self._config.interfaces) def update_dhcp(self): mgr = dnsmasq.DHCPManager() mgr.delete_all_config() - for network in self.config.networks: + for network in self._config.networks: real_ifname = self.ip_mgr.generic_to_host(network.interface.ifname) mgr.update_network_dhcp_config(real_ifname, network) mgr.restart() def update_metadata(self): mgr = metadata.MetadataManager() - should_restart = mgr.networks_have_changed(self.config) - mgr.save_config(self.config) + should_restart = mgr.networks_have_changed(self._config) + mgr.save_config(self._config) if should_restart: mgr.restart() else: @@ -86,26 +106,26 @@ class Manager(object): def update_bgp_and_radv(self): mgr = bird.BirdManager() - mgr.save_config(self.config, self.ip_mgr.generic_mapping) + mgr.save_config(self._config, self.ip_mgr.generic_mapping) mgr.restart() def update_firewall(self): mgr = iptables.IPTablesManager() - mgr.save_config(self.config, self.ip_mgr.generic_mapping) + mgr.save_config(self._config, self.ip_mgr.generic_mapping) mgr.restart() def update_routes(self, cache): mgr = ip.IPManager() - mgr.update_default_gateway(self.config) - mgr.update_host_routes(self.config, cache) + mgr.update_default_gateway(self._config) + mgr.update_host_routes(self._config, cache) def update_arp(self): mgr = arp.ARPManager() mgr.send_gratuitous_arp_for_floating_ips( - self.config, + self._config, self.ip_mgr.generic_to_host ) - mgr.remove_stale_entries(self.config) + mgr.remove_stale_entries(self._config) def get_interfaces(self): return self.ip_mgr.get_interfaces() @@ -123,6 +143,133 @@ class Manager(object): rules.append(re.sub('([\s!])(ge\d+([\s:]|$))', r'\1$\2', virt_data)) return '\n'.join(rules) + def get_config_or_default(self): + # This is a hack to provide compatability with the original API, see + # Manager.config() + if not self._config: + return models.RouterConfiguration() + else: + return self._config + + +class LoadBalancerManager(ServiceManagerBase): + def __init__(self, state_path='.'): + super(LoadBalancerManager, self).__init__(state_path) + self.lb_manager = loadbalancer.get_loadbalancer_driver( + # xxx pull from cfg + loadbalancer.CONFIGURED_LB_DRIVER)() + + def update_config(self, config, cache): + self._config = config + self.lb_manager.update_config(self.config) + + +SERVICE_MANAGER_MAP = { + 'router': RouterManager, + 'loadbalancer': LoadBalancerManager, +} + + +class Manager(object): + def __init__(self, state_path='.'): + self.state_path = os.path.abspath(state_path) + self.ip_mgr = ip.IPManager() + self.ip_mgr.ensure_mapping() + + # Holds the common system config + self._system_config = models.SystemConfiguration() + + # Holds config models for various services (router, loadbalancer) + self._service_configs = [] + + self._service_managers = { + 'system': SystemManager() + } + self._load_managers() + + def _load_managers(self): + for svc in settings.ENABLED_SERVICES: + manager = SERVICE_MANAGER_MAP.get(svc) + if manager: + self._service_managers[svc] = manager() + + def get_manager(self, service): + try: + return self._service_managers[service] + except: + raise Exception('No such service manager loaded for appliance ' + 'service %s' % service) + + def management_address(self, ensure_configuration=False): + return self.ip_mgr.get_management_address(ensure_configuration) + + @property + def router(self): + """Returns the router manager. + This is mostly to keep compat with the existing API. + """ + return self.get_manager('router') + + @property + def system_config(self): + """Make config a read-only property. + + To update the value, update_config() must called to change the global + state of appliance. + """ + + return self._system_config + + @property + def service_configs(self): + """Make config a read-only property. + + To update the value, update_config() must called to change the global + state of router. + """ + + return self._service_configs + + def update_config(self, system_config, service_configs, cache): + self._system_config = system_config + self._service_configs = service_configs + + # first update the system config + manager = self.get_manager(self.system_config.service_name) + manager.update_config(self.system_config, cache) + + for svc_cfg in self.service_configs: + manager = self.get_manager(svc_cfg.service_name) + manager.update_config(svc_cfg, cache) + + @property + def config(self): + out = {} + if 'router' in self._service_managers: + # The original appliance API provides router config + # in the root 'configuration' key. We want to move that + # to the 'services' bucket but provide compat to those who might + # still be expecting it in the root. This seeds the root with the + # default empty values if no router is associated with the + # appliance and allows for + # ['configuration']['services']['router'] to be None at the same + # time. + router_cfg = self.router.get_config_or_default().to_dict() + out = router_cfg + else: + out = {} + + out['services'] = {} + for svc in SERVICE_MANAGER_MAP: + try: + manager = self.get_manager(svc) + except: + continue + out['services'][svc] = manager.config + + out['system'] = self.system_config + return out + class ManagerProxy(object): def __init__(self): diff --git a/akanda/router/models.py b/akanda/router/models.py index a169561..dbe5319 100644 --- a/akanda/router/models.py +++ b/akanda/router/models.py @@ -365,6 +365,7 @@ class Network(ModelBase): TYPE_INTERNAL = 'internal' TYPE_ISOLATED = 'isolated' TYPE_MANAGEMENT = 'management' + TYPE_LOADBALANCER = 'loadbalancer' # TODO(mark): add subnet support for Quantum subnet host routes @@ -406,7 +407,8 @@ class Network(ModelBase): @network_type.setter def network_type(self, value): network_types = (self.TYPE_EXTERNAL, self.TYPE_INTERNAL, - self.TYPE_ISOLATED, self.TYPE_MANAGEMENT) + self.TYPE_ISOLATED, self.TYPE_MANAGEMENT, + self.TYPE_LOADBALANCER) if value not in network_types: msg = ('network must be one of %s not (%s).' % ('|'.join(network_types), value)) @@ -451,6 +453,13 @@ class Network(ModelBase): @classmethod def from_dict(cls, d): + missing = [] + for k in ['network_id', 'interface']: + if not d.get(k): + missing.append(k) + if missing: + raise ValueError('Missing required data: %s.' % missing) + return cls( d['network_id'], interface=Interface.from_dict(d['interface']), @@ -463,15 +472,234 @@ class Network(ModelBase): subnets=[Subnet.from_dict(s) for s in d.get('subnets', [])]) -class Configuration(ModelBase): +class LoadBalancer(ModelBase): + def __init__(self, id_, tenant_id, name, admin_state_up, status, + vip_address, vip_port=None, listeners=()): + self.id = id_ + self.tenant_id = tenant_id + self.name = name + self.admin_state_up = admin_state_up + self.status = status + self.vip_address = vip_address + self.vip_port = vip_port + self.listeners = listeners + + @classmethod + def from_dict(cls, d): + if d.get('listeners'): + d['listeners'] = [ + Listener.from_dict(l) for l in d.get('listeners', []) + ] + if d.get('vip_port'): + d['vip_port'] = Port.from_dict(d.get('vip_port')) + out = cls( + d['id'], + d['tenant_id'], + d['name'], + d['admin_state_up'], + d['status'], + d['vip_address'], + d['vip_port'], + d['listeners'], + ) + return out + + +class Listener(ModelBase): + def __init__(self, id_, tenant_id, name, admin_state_up, protocol, + protocol_port, default_pool=None): + self.id = id_ + self.tenant_id = tenant_id + self.name = name + self.admin_state_up = admin_state_up + self.protocol = protocol + self.protocol_port = protocol_port + self.default_pool = default_pool + + @classmethod + def from_dict(cls, d): + if d.get('default_pool'): + def_pool = Pool.from_dict(d['default_pool']) + else: + def_pool = None + + return cls( + d['id'], + d['tenant_id'], + d['name'], + d['admin_state_up'], + d['protocol'], + d['protocol_port'], + def_pool, + ) + + def to_dict(self): + fields = ('id', 'tenant_id', 'name', 'admin_state_up', 'protocol', + 'protocol_port') + out = dict((f, getattr(self, f)) for f in fields) + if self.default_pool: + out['default_pool'] = self.default_pool.to_dict() + else: + out['default_pool'] = None + return out + + +class Pool(ModelBase): + def __init__(self, id_, tenant_id, name, admin_state_up, lb_algorithm, + protocol, healthmonitor=None, session_persistence=None, + members=()): + self.id = id_ + self.tenant_id = tenant_id + self.name = name + self.admin_state_up = admin_state_up + self.lb_algorithm = lb_algorithm + self.protocol = protocol + self.healthmonitor = healthmonitor + self.session_persistence = session_persistence + self.members = members + + @classmethod + def from_dict(cls, d): + return cls( + d['id'], + d['tenant_id'], + d['name'], + d['admin_state_up'], + d['lb_algorithm'], + d['protocol'], + d.get('healthmonitor'), + d.get('session_persistence'), + [Member.from_dict(m) for m in d.get('members', [])], + ) + + def to_dict(self): + fields = ('id', 'tenant_id', 'name', 'admin_state_up', + 'lb_algorithm', 'protocol', 'healthmonitor', + 'session_persistence') + out = dict((f, getattr(self, f)) for f in fields) + out['members'] = [m.to_dict() for m in self.members] + return out + + +class Member(ModelBase): + def __init__(self, id_, tenant_id, admin_state_up, address, protocol_port, + weight, subnet=None): + self.id = id_ + self.tenant_id = tenant_id + self.admin_state_up = admin_state_up + self.address = str(netaddr.IPAddress(address)) + self.protocol_port = protocol_port + self.weight = weight + self.subnet = subnet + + @classmethod + def from_dict(cls, d): + return cls( + d['id'], + d['tenant_id'], + d['admin_state_up'], + d['address'], + d['protocol_port'], + d['weight'], + ) + + def to_dict(self): + fields = ('id', 'tenant_id', 'admin_state_up', 'address', + 'protocol_port', 'weight', 'subnet') + return dict((f, getattr(self, f)) for f in fields) + + +class Port(ModelBase): + def __init__(self, id_, device_id='', fixed_ips=None, mac_address='', + network_id='', device_owner='', name=''): + self.id = id_ + self.device_id = device_id + self.fixed_ips = fixed_ips or [] + self.mac_address = mac_address + self.network_id = network_id + self.device_owner = device_owner + self.name = name + + @classmethod + def from_dict(cls, d): + return cls( + d['id'], + d['device_id'], + fixed_ips=[FixedIp.from_dict(fip) for fip in d['fixed_ips']], + mac_address=d['mac_address'], + network_id=d['network_id'], + device_owner=d['device_owner'], + name=d['name']) + + def to_dict(self): + fields = ('id', 'device_id', 'mac_address', 'network_id', + 'device_owner', 'name') + out = dict((f, getattr(self, f)) for f in fields) + out['fixed_ips'] = [fip.to_dict() for fip in self.fixed_ips] + return out + + +class FixedIp(ModelBase): + def __init__(self, subnet_id, ip_address): + self.subnet_id = subnet_id + self.ip_address = netaddr.IPAddress(ip_address) + + @classmethod + def from_dict(cls, d): + return cls(d['subnet_id'], d['ip_address']) + + def to_dict(self): + fields = ('subnet_id', 'ip_address') + return dict((f, getattr(self, f)) for f in fields) + + +class SystemConfiguration(ModelBase): + service_name = 'system' + def __init__(self, conf_dict={}): + self.tenant_id = conf_dict.get('tenant_id') + self.hostname = conf_dict.get('hostname') + self.networks = [ + Network.from_dict(n) for n in conf_dict.get('networks', [])] + + def validate(self): + # TODO: Improve this interface, it currently sucks. + errors = [] + for attr in ['tenant_id', 'hostname']: + if not getattr(self, attr): + errors.append((attr, 'Config does not contain a %s' % attr)) + return errors + + @property + def management_address(self): + addrs = [] + for net in self.networks: + if net.is_management_network: + addrs.extend((net.interface.first_v4, net.interface.first_v6)) + + addrs = sorted(a for a in addrs if a) + + if addrs: + return addrs[0] + + @property + def interfaces(self): + return [n.interface for n in self.networks if n.interface] + + def to_dict(self): + fields = ('tenant_id', 'hostname', 'management_address', 'interfaces') + return dict((f, getattr(self, f)) for f in fields) + + +class RouterConfiguration(SystemConfiguration): + service_name = 'router' + + def __init__(self, conf_dict={}): + super(RouterConfiguration, self).__init__(conf_dict) gw = conf_dict.get('default_v4_gateway') self.default_v4_gateway = netaddr.IPAddress(gw) if gw else None self.asn = conf_dict.get('asn', DEFAULT_AS) self.neighbor_asn = conf_dict.get('neighbor_asn', self.asn) - self.networks = [ - Network.from_dict(n) for n in conf_dict.get('networks', [])] - self.static_routes = [StaticRoute(*r) for r in conf_dict.get('static_routes', [])] @@ -491,17 +719,13 @@ class Configuration(ModelBase): FloatingIP.from_dict(fip) for fip in conf_dict.get('floating_ips', []) ] - self.tenant_id = conf_dict.get('tenant_id') - - self.hostname = conf_dict.get('hostname') self._attach_floating_ips(self.floating_ips) def validate(self): """Validate anchor rules to ensure that ifaces and tables exist.""" - errors = [] - interfaces = set(n.interface.ifname for n in self.networks) + errors = [] for anchor in self.anchors: for rule in anchor.rules: for iface in (rule.interface, rule.destination_interface): @@ -561,18 +785,49 @@ class Configuration(ModelBase): if addrs: return addrs[0] - @property - def interfaces(self): - return [n.interface for n in self.networks if n.interface] - @property - def management_address(self): - addrs = [] - for net in self.networks: - if net.is_management_network: - addrs.extend((net.interface.first_v4, net.interface.first_v6)) +class LoadBalancerConfiguration(SystemConfiguration): + service_name = 'loadbalancer' - addrs = sorted(a for a in addrs if a) + def __init__(self, conf_dict={}): + super(LoadBalancerConfiguration, self).__init__(conf_dict) + self.id = conf_dict.get('id') + self.name = conf_dict.get('name') + if conf_dict: + self._loadbalancer = LoadBalancer.from_dict(conf_dict) + self.vip_port = self._loadbalancer.vip_port + self.vip_address = self._loadbalancer.vip_address + self.listeners = self._loadbalancer.listeners + else: + self.vip_port = None + self.vip_address = None + self.listeners = [] - if addrs: - return addrs[0] + def validate(self): + super(LoadBalancerConfiguration, self).validate() + errors = [] + if not self.id: + errors.append(['id', 'Missing in config id']) + return errors + + def to_dict(self): + if self.vip_port: + vip_port = self.vip_port.to_dict() + else: + vip_port = {} + return { + 'id': self.id, + 'name': self.name, + 'vip_port': vip_port, + 'vip_address': self.vip_address, + 'listeners': [l.to_dict() for l in self.listeners], + } + +SERVICE_MAP = { + RouterConfiguration.service_name: RouterConfiguration, + LoadBalancerConfiguration.service_name: LoadBalancerConfiguration, +} + + +def get_config_model(service): + return SERVICE_MAP[service] diff --git a/akanda/router/settings.py b/akanda/router/settings.py new file mode 100644 index 0000000..90ec9f0 --- /dev/null +++ b/akanda/router/settings.py @@ -0,0 +1,13 @@ + +# Configures which advanced service drivers are loaded by this +# instance of the appliance. +ENABLED_SERVICES = ['router'] + +# If akanda_local_settings.py is located in your python path, +# it can be used to override the defaults. DIB will install this +# into /usr/local/share/akanda and append that path to the gunicorn's +# python path. +try: + from akanda_local_settings import * # noqa +except ImportError: + pass diff --git a/akanda/router/utils.py b/akanda/router/utils.py index 4ca17cf..82b069d 100644 --- a/akanda/router/utils.py +++ b/akanda/router/utils.py @@ -14,7 +14,6 @@ # License for the specific language governing permissions and limitations # under the License. - import functools import json import os @@ -27,6 +26,9 @@ import netaddr from akanda.router import models +DEFAULT_ENABLED_SERVICES = ['router'] +VALID_SERVICES = ['router', 'loadbalancer'] + def execute(args, root_helper=None): if root_helper: diff --git a/ansible/main.yml b/ansible/main.yml index 8e542e1..de8f2ca 100644 --- a/ansible/main.yml +++ b/ansible/main.yml @@ -12,7 +12,7 @@ do_cleanup: True router_appliance: True update_kernel: True - + enabled_advanced_services: "router" tasks: - include: tasks/debian_backports.yml when: ansible_distribution == "Debian" and ansible_distribution_release == "wheezy" diff --git a/ansible/tasks/akanda.yml b/ansible/tasks/akanda.yml index 085ae9b..29158db 100644 --- a/ansible/tasks/akanda.yml +++ b/ansible/tasks/akanda.yml @@ -34,6 +34,15 @@ - metadata - akanda-router-api-server +- name: create /usr/local/share/akanda/ + file: path=/usr/local/share/akanda state=directory + +- name: make /usr/local/share/akanda/ importable + copy: dest=/usr/local/share/akanda/__init__.py content='' + +- name: install akanda_local_settings.py + copy: dest=/usr/local/share/akanda/akanda_local_settings.py content='ENABLED_SERVICES = {{enabled_advanced_services.split(',')}}\n' + - name: update-rc command: update-rc.d akanda-router-api-server start diff --git a/diskimage-builder/elements/akanda/README.rst b/diskimage-builder/elements/akanda/README.rst index 0d61226..0d5a188 100644 --- a/diskimage-builder/elements/akanda/README.rst +++ b/diskimage-builder/elements/akanda/README.rst @@ -1,3 +1,9 @@ This is the base element for building an Akanda appliance image. Ansible is required on the local system. + +Advanced service drivers may be enabled in the appliance by setting +``DIB_AKANDA_ADVANCED_SERVICES``. This defaults to enabling only the +router driver, but you may enabled other avialable drivers ie: + +DIB_AKANDA_ADVANCED_SERVICES=router,loadbalancer diff --git a/diskimage-builder/elements/akanda/install.d/akanda-source-install/70-akanda b/diskimage-builder/elements/akanda/install.d/akanda-source-install/70-akanda index 743d9b6..282223f 100755 --- a/diskimage-builder/elements/akanda/install.d/akanda-source-install/70-akanda +++ b/diskimage-builder/elements/akanda/install.d/akanda-source-install/70-akanda @@ -2,8 +2,10 @@ set -eux set -o pipefail +DIB_AKANDA_ADVANCED_SERVICES=${DIB_AKANDA_ADVANCED_SERVICES:-"router"} + APP_SRC_DIR="/tmp/akanda-appliance" [ -d "${APP_SRC_DIR}" ] || exit 0 -ansible-playbook -i "localhost," -c local $APP_SRC_DIR/ansible/main.yml +ansible-playbook -i "localhost," -c local -e enabled_advanced_services="$DIB_AKANDA_ADVANCED_SERVICES" $APP_SRC_DIR/ansible/main.yml diff --git a/diskimage-builder/elements/nginx/post-install.d/99-disable-default-nginx b/diskimage-builder/elements/nginx/post-install.d/99-disable-default-nginx new file mode 100755 index 0000000..2f09699 --- /dev/null +++ b/diskimage-builder/elements/nginx/post-install.d/99-disable-default-nginx @@ -0,0 +1,16 @@ +#!/bin/bash -xe +# Copyright (c) 2015 Akanda, Inc. All Rights Reserved. +# +# 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. + +rm -rf /etc/nginx/sites-enabled/default diff --git a/scripts/etc/init.d/akanda-router-api-server b/scripts/etc/init.d/akanda-router-api-server index 5356c87..8051075 100755 --- a/scripts/etc/init.d/akanda-router-api-server +++ b/scripts/etc/init.d/akanda-router-api-server @@ -13,7 +13,7 @@ PATH=/bin:/usr/bin:/sbin:/usr/sbin DAEMON="/usr/local/bin/gunicorn" NAME="akanda-router-api-server" -OPTIONS="-c /etc/akanda_gunicorn_config akanda.router.api.server:app" +OPTIONS="--pythonpath /usr/local/share/akanda -c /etc/akanda_gunicorn_config akanda.router.api.server:app" PIDFILE=/var/run/gunicorn.pid test -x $DAEMON || exit 0 diff --git a/setup.cfg b/setup.cfg index cc0b734..c6898e0 100644 --- a/setup.cfg +++ b/setup.cfg @@ -42,8 +42,8 @@ all_files = 1 build-dir = doc/build source-dir = doc/source -[nosetests] -where = test -verbosity = 2 -detailed-errors = 1 -cover-package = akanda +#[nosetests] +#where = test +#verbosity = 2 +#detailed-errors = 1 +#cover-package = akanda diff --git a/test/unit/api/v1/test_system.py b/test/unit/api/v1/test_system.py index 5e1b260..d88b7fd 100644 --- a/test/unit/api/v1/test_system.py +++ b/test/unit/api/v1/test_system.py @@ -26,9 +26,15 @@ import flask import json import mock +from akanda.router import manager from akanda.router.api import v1 +SYSTEM_CONFIG = { + 'tenant_id': 'foo_tenant_id', + 'hostname': 'foohostname', +} + class SystemAPITestCase(unittest.TestCase): """ This test case contains the unit tests for the Python server implementation @@ -54,7 +60,7 @@ class SystemAPITestCase(unittest.TestCase): 'unsupported platform' ) def test_get_interface(self): - with mock.patch.object(v1.system.manager, 'get_interface') as get_if: + with mock.patch.object(v1.system.manager.router, 'get_interface') as get_if: get_if.return_value = 'ge1' result = self.test_app.get('/v1/system/interface/ge1') get_if.assert_called_once_with('ge1') @@ -68,7 +74,7 @@ class SystemAPITestCase(unittest.TestCase): 'unsupported platform' ) def test_get_interfaces(self): - with mock.patch.object(v1.system.manager, 'get_interfaces') as get_ifs: + with mock.patch.object(v1.system.manager.router, 'get_interfaces') as get_ifs: get_ifs.return_value = ['ge0', 'ge1'] result = self.test_app.get('/v1/system/interfaces') get_ifs.assert_called_once_with() @@ -81,14 +87,29 @@ class SystemAPITestCase(unittest.TestCase): not distutils.spawn.find_executable('ip'), 'unsupported platform' ) - def test_get_configuration(self): + @mock.patch.object(manager, 'settings') + @mock.patch.object(v1.system, 'settings') + def test_get_configuration(self, fake_api_settings, fake_mgr_settings): + fake_api_settings.ENABLED_SERVICES = ['router', 'loadbalancer'] + fake_mgr_settings.ENABLED_SERVICES = ['router', 'loadbalancer'] + result = self.test_app.get('/v1/system/config') expected = { 'configuration': { 'address_book': {}, + 'anchors': [], 'networks': [], + 'services': { + 'loadbalancer': None, + 'router': None + }, 'static_routes': [], - 'anchors': [] + 'system': { + 'hostname': None, + 'interfaces': [], + 'management_address': None, + 'tenant_id': None + } } } self.assertEqual(json.loads(result.data), expected) @@ -102,7 +123,7 @@ class SystemAPITestCase(unittest.TestCase): self.assertEqual(result.status_code, 415) def test_put_configuration_returns_422_for_ValueError(self): - with mock.patch('akanda.router.models.Configuration') as Config: + with mock.patch('akanda.router.models.RouterConfiguration') as Config: Config.side_effect = ValueError result = self.test_app.put( '/v1/system/config', @@ -112,11 +133,11 @@ class SystemAPITestCase(unittest.TestCase): self.assertEqual(result.status_code, 422) def test_put_configuration_returns_422_for_errors(self): - with mock.patch('akanda.router.models.Configuration') as Config: + with mock.patch('akanda.router.models.SystemConfiguration') as Config: Config.return_value.validate.return_value = ['error1'] result = self.test_app.put( '/v1/system/config', - data=json.dumps({'networks': [{}]}), # malformed dict + data=json.dumps(SYSTEM_CONFIG), content_type='application/json' ) self.assertEqual(result.status_code, 422) @@ -129,13 +150,149 @@ class SystemAPITestCase(unittest.TestCase): not distutils.spawn.find_executable('ip'), 'unsupported platform' ) - def test_put_configuration_returns_200(self): - with mock.patch.object(v1.system.manager, 'update_config') as update: - result = self.test_app.put( - '/v1/system/config', - data=json.dumps({}), - content_type='application/json' - ) - self.assertEqual(result.status_code, 200) - self.assertTrue(json.loads(result.data)) + + @mock.patch('akanda.router.api.v1.system._get_cache') + @mock.patch('akanda.router.models.SystemConfiguration') + @mock.patch.object(v1.system.manager, 'update_config') + def test_put_configuration_returns_200(self, mock_update, + fake_system_config, fake_cache): + fake_cache.return_value = 'fake_cache' + sys_config_obj = mock.Mock() + sys_config_obj.validate = mock.Mock() + sys_config_obj.validate.return_value = [] + fake_system_config.return_value = sys_config_obj + + result = self.test_app.put( + '/v1/system/config', + data=json.dumps({ + 'tenant_id': 'foo_tenant_id', + 'hostname': 'foo_hostname', + }), + content_type='application/json' + ) + + self.assertEqual(result.status_code, 200) + self.assertTrue(json.loads(result.data)) + mock_update.assert_called_with( + cache='fake_cache', service_configs=[], system_config=sys_config_obj) + + @mock.patch('akanda.router.manager.Manager.config', + new_callable=mock.PropertyMock, return_value={}) + @mock.patch('akanda.router.api.v1.system._get_cache') + @mock.patch('akanda.router.models.RouterConfiguration') + @mock.patch('akanda.router.models.SystemConfiguration') + @mock.patch.object(v1.system.manager, 'update_config') + def test_put_configuration_with_router(self, mock_update, + fake_system_config, fake_router_config, fake_cache, fake_config): + fake_config.return_value = 'foo' + fake_cache.return_value = 'fake_cache' + sys_config_obj = mock.Mock() + sys_config_obj.validate = mock.Mock() + sys_config_obj.validate.return_value = [] + fake_system_config.return_value = sys_config_obj + + router_config_obj = mock.Mock() + router_config_obj.validate = mock.Mock() + router_config_obj.validate.return_value = [] + fake_router_config.return_value = router_config_obj + + + result = self.test_app.put( + '/v1/system/config', + data=json.dumps({ + 'tenant_id': 'foo_tenant_id', + 'hostname': 'foo_hostname', + 'asn': 'foo_asn', + }), + content_type='application/json' + ) + self.assertEqual(result.status_code, 200) + self.assertTrue(json.loads(result.data)) + mock_update.assert_called_with( + cache='fake_cache', service_configs=[router_config_obj], + system_config=sys_config_obj) + + @mock.patch('akanda.router.models.get_config_model') + @mock.patch.object(manager, 'settings') + @mock.patch.object(v1.system, 'settings') + @mock.patch('akanda.router.manager.Manager.config', + new_callable=mock.PropertyMock, return_value={}) + @mock.patch('akanda.router.api.v1.system._get_cache') + @mock.patch('akanda.router.models.LoadBalancerConfiguration') + @mock.patch('akanda.router.models.SystemConfiguration') + @mock.patch.object(v1.system.manager, 'update_config') + def test_put_configuration_with_adv_services(self, mock_update, + fake_system_config, fake_lb_config, fake_cache, fake_config, + fake_api_settings, fake_mgr_settings, fake_get_config_model): + fake_api_settings.ENABLED_SERVICES = ['loadbalancer'] + fake_mgr_settings.ENABLED_SERVICES = ['loadbalancer'] + fake_config.return_value = 'foo' + fake_cache.return_value = 'fake_cache' + sys_config_obj = mock.Mock() + sys_config_obj.validate = mock.Mock() + sys_config_obj.validate.return_value = [] + fake_system_config.return_value = sys_config_obj + + lb_config_obj = mock.Mock() + lb_config_obj.validate = mock.Mock() + lb_config_obj.validate.return_value = [] + fake_lb_config.return_value = lb_config_obj + fake_get_config_model.return_value = fake_lb_config + + result = self.test_app.put( + '/v1/system/config', + data=json.dumps({ + 'tenant_id': 'foo_tenant_id', + 'hostname': 'foo_hostname', + 'services': { + 'loadbalancer': {'id': 'foo'} + } + }), + content_type='application/json' + ) + self.assertEqual(result.status_code, 200) + self.assertTrue(json.loads(result.data)) + mock_update.assert_called_with( + cache='fake_cache', service_configs=[lb_config_obj], + system_config=sys_config_obj) + + @mock.patch('akanda.router.models.get_config_model') + @mock.patch.object(manager, 'settings') + @mock.patch.object(v1.system, 'settings') + @mock.patch('akanda.router.manager.Manager.config', + new_callable=mock.PropertyMock, return_value={}) + @mock.patch('akanda.router.api.v1.system._get_cache') + @mock.patch('akanda.router.models.LoadBalancerConfiguration') + @mock.patch('akanda.router.models.SystemConfiguration') + @mock.patch.object(v1.system.manager, 'update_config') + def test_put_configuration_with_disabled_svc_returns_400(self, mock_update, + fake_system_config, fake_lb_config, fake_cache, fake_config, + fake_api_settings, fake_mgr_settings, fake_get_config_model): + fake_api_settings.ENABLED_SERVICES = ['foo'] + fake_mgr_settings.ENABLED_SERVICES = ['foo'] + fake_config.return_value = 'foo' + fake_cache.return_value = 'fake_cache' + sys_config_obj = mock.Mock() + sys_config_obj.validate = mock.Mock() + sys_config_obj.validate.return_value = [] + fake_system_config.return_value = sys_config_obj + + lb_config_obj = mock.Mock() + lb_config_obj.validate = mock.Mock() + lb_config_obj.validate.return_value = [] + fake_lb_config.return_value = lb_config_obj + fake_get_config_model.return_value = fake_lb_config + + result = self.test_app.put( + '/v1/system/config', + data=json.dumps({ + 'tenant_id': 'foo_tenant_id', + 'hostname': 'foo_hostname', + 'services': { + 'loadbalancer': {'id': 'foo'} + } + }), + content_type='application/json' + ) + self.assertEqual(result.status_code, 400) diff --git a/test/unit/drivers/test_arp.py b/test/unit/drivers/test_arp.py index 6e17596..c8002bb 100644 --- a/test/unit/drivers/test_arp.py +++ b/test/unit/drivers/test_arp.py @@ -94,7 +94,7 @@ class ARPTest(unittest2.TestCase): ]) def test_send_gratuitous_arp_for_config(self): - config = models.Configuration({ + config = models.RouterConfiguration({ 'networks': [{ 'network_id': 'ABC456', 'interface': { diff --git a/test/unit/drivers/test_iptables.py b/test/unit/drivers/test_iptables.py index a590ea6..c5d06d1 100644 --- a/test/unit/drivers/test_iptables.py +++ b/test/unit/drivers/test_iptables.py @@ -7,7 +7,7 @@ import netaddr from akanda.router import models from akanda.router.drivers import iptables -CONFIG = models.Configuration({ +CONFIG = models.RouterConfiguration({ 'networks': [{ 'network_id': 'ABC123', 'interface': { @@ -127,16 +127,16 @@ V6_OUTPUT = [ ] -class TestIPTablesConfiguration(TestCase): +class TestIPTablesRouterConfiguration(TestCase): def setUp(self): - super(TestIPTablesConfiguration, self).setUp() + super(TestIPTablesRouterConfiguration, self).setUp() self.execute = mock.patch('akanda.router.utils.execute').start() self.replace = mock.patch('akanda.router.utils.replace_file').start() self.patches = [self.execute, self.replace] def tearDown(self): - super(TestIPTablesConfiguration, self).tearDown() + super(TestIPTablesRouterConfiguration, self).tearDown() for p in self.patches: p.stop() diff --git a/test/unit/drivers/test_route.py b/test/unit/drivers/test_route.py index 5a34ea9..fbe3b32 100644 --- a/test/unit/drivers/test_route.py +++ b/test/unit/drivers/test_route.py @@ -161,7 +161,7 @@ class RouteTest(unittest2.TestCase): ) def test_update_default_no_inputs(self): - c = models.Configuration({}) + c = models.RouterConfiguration({}) with mock.patch.object(self.mgr, '_set_default_gateway') as set: set.side_effect = AssertionError( 'should not try to set default gw' @@ -169,7 +169,7 @@ class RouteTest(unittest2.TestCase): self.mgr.update_default_gateway(c) def test_update_default_v4_from_gateway(self): - c = models.Configuration({'default_v4_gateway': '172.16.77.1'}) + c = models.RouterConfiguration({'default_v4_gateway': '172.16.77.1'}) with mock.patch.object(self.mgr, '_set_default_gateway') as set: self.mgr.update_default_gateway(c) set.assert_called_once_with(c.default_v4_gateway, None) @@ -189,7 +189,7 @@ class RouteTest(unittest2.TestCase): subnets=[subnet], network_type='external', ) - c = models.Configuration({'networks': [network]}) + c = models.RouterConfiguration({'networks': [network]}) with mock.patch.object(self.mgr, '_set_default_gateway') as set: self.mgr.update_default_gateway(c) net = c.networks[0] @@ -217,7 +217,7 @@ class RouteTest(unittest2.TestCase): subnets=[subnet, subnet2], network_type='external', ) - c = models.Configuration({'networks': [network]}) + c = models.RouterConfiguration({'networks': [network]}) with mock.patch.object(self.mgr, '_set_default_gateway') as set: self.mgr.update_default_gateway(c) net = c.networks[0] @@ -239,7 +239,7 @@ class RouteTest(unittest2.TestCase): subnets=[subnet], network_type='external', ) - c = models.Configuration({'networks': [network]}) + c = models.RouterConfiguration({'networks': [network]}) with mock.patch.object(self.mgr, '_set_default_gateway') as set: self.mgr.update_default_gateway(c) net = c.networks[0] @@ -267,7 +267,7 @@ class RouteTest(unittest2.TestCase): subnets=[subnet, subnet2], network_type='external', ) - c = models.Configuration({'networks': [network]}) + c = models.RouterConfiguration({'networks': [network]}) with mock.patch.object(self.mgr, '_set_default_gateway') as set: self.mgr.update_default_gateway(c) net = c.networks[0] @@ -292,7 +292,7 @@ class RouteTest(unittest2.TestCase): interface=dict(ifname='ge0', addresses=['fe80::2']), subnets=[subnet] ) - c = models.Configuration({'networks': [network]}) + c = models.RouterConfiguration({'networks': [network]}) cache = make_region().configure('dogpile.cache.memory') with mock.patch.object(self.mgr, 'sudo') as sudo: @@ -319,7 +319,7 @@ class RouteTest(unittest2.TestCase): # Empty the host_routes list sudo.reset_mock() subnet['host_routes'] = [] - c = models.Configuration({'networks': [network]}) + c = models.RouterConfiguration({'networks': [network]}) self.mgr.update_host_routes(c, cache) sudo.assert_called_once_with( '-4', 'route', 'del', '192.240.128.0/20', 'via', @@ -336,7 +336,7 @@ class RouteTest(unittest2.TestCase): 'destination': '192.220.128.0/20', 'nexthop': '192.168.89.3' }] - c = models.Configuration({'networks': [network]}) + c = models.RouterConfiguration({'networks': [network]}) self.mgr.update_host_routes(c, cache) self.assertEqual(sudo.call_args_list, [ mock.call('-4', 'route', 'add', '192.240.128.0/20', @@ -354,7 +354,7 @@ class RouteTest(unittest2.TestCase): 'destination': '192.185.128.0/20', 'nexthop': '192.168.89.4' }] - c = models.Configuration({'networks': [network]}) + c = models.RouterConfiguration({'networks': [network]}) self.mgr.update_host_routes(c, cache) self.assertEqual(sudo.call_args_list, [ mock.call('-4', 'route', 'del', '192.220.128.0/20', @@ -376,7 +376,7 @@ class RouteTest(unittest2.TestCase): 'nexthop': '192.168.90.1' }] )) - c = models.Configuration({'networks': [network]}) + c = models.RouterConfiguration({'networks': [network]}) self.mgr.update_host_routes(c, cache) self.assertEqual(sudo.call_args_list, [ mock.call('-4', 'route', 'add', '192.240.128.0/20', @@ -388,7 +388,7 @@ class RouteTest(unittest2.TestCase): sudo.reset_mock() network['subnets'][0]['host_routes'] = [] network['subnets'][1]['host_routes'] = [] - c = models.Configuration({'networks': [network]}) + c = models.RouterConfiguration({'networks': [network]}) self.mgr.update_host_routes(c, cache) self.assertEqual(sudo.call_args_list, [ mock.call('-4', 'route', 'del', '192.185.128.0/20', @@ -416,7 +416,7 @@ class RouteTest(unittest2.TestCase): interface=dict(ifname='ge0', addresses=['fe80::2']), subnets=[subnet] ) - c = models.Configuration({'networks': [network]}) + c = models.RouterConfiguration({'networks': [network]}) cache = make_region().configure('dogpile.cache.memory') with mock.patch.object(self.mgr, 'sudo') as sudo: diff --git a/test/unit/fakes.py b/test/unit/fakes.py new file mode 100644 index 0000000..1ea050e --- /dev/null +++ b/test/unit/fakes.py @@ -0,0 +1,138 @@ + +from copy import copy + + +FAKE_SYSTEM_DICT = { + "tenant_id": "d22b149cee9b4eac8349c517eda00b89", + "hostname": "ak-loadbalancer-d22b149cee9b4eac8349c517eda00b89", + "networks": [ + { + "v4_conf_service": "static", + "network_type": "loadbalancer", + "v6_conf_service": "static", + "network_id": "b7fc9b39-401c-47cc-a07d-9f8cde75ccbf", + "allocations": [], + "subnets": [ + { + "host_routes": [], + "cidr": "192.168.0.0/24", + "gateway_ip": "192.168.0.1", + "dns_nameservers": [], + "dhcp_enabled": True + }, + { + "host_routes": [], + "cidr": "fdd6:a1fa:cfa8:6af6::/64", + "gateway_ip": "fdd6:a1fa:cfa8:6af6::1", + "dns_nameservers": [], + "dhcp_enabled": False + }], + "interface": { + "ifname": "ge1", + "addresses": [ + "192.168.0.137/24", "fdd6:a1fa:cfa8:6af6:f816:3eff:fea0:8082/64" + ] + }, + }, + { + "v4_conf_service": "static", + "network_type": "management", + "v6_conf_service": "static", + "network_id": "43dc2fad-f6f9-4668-9695-fed50f7768aa", + "allocations": [], + "subnets": [ + { + "host_routes": [], + "cidr": "fdca:3ba5:a17a:acda::/64", + "gateway_ip": "fdca:3ba5:a17a:acda::1", + "dns_nameservers": [], + "dhcp_enabled": True} + ], + "interface": { + "ifname": "ge0", + "addresses": ["fdca:3ba5:a17a:acda:f816:3eff:fee0:e1b0/64"] + }, + }] +} + +FAKE_LOADBALANCER_DICT = { + "id": "8ac54799-b143-48e5-94d4-e5e989592229", + "status": "ACTIVE", + "name": "balancer1", + "admin_state_up": True, + "tenant_id": "d22b149cee9b4eac8349c517eda00b89", + "vip_port": { + "name": "loadbalancer-8ac54799-b143-48e5-94d4-e5e989592229", + "network_id": "b7fc9b39-401c-47cc-a07d-9f8cde75ccbf", + "device_owner": "neutron:LOADBALANCERV2", + "mac_address": "fa:16:3e:a0:80:82", + "fixed_ips": [ + { + "subnet_id": "8c58b558-be54-45de-9873-169fe845bb80", + "ip_address": "192.168.0.137" + }, + { + "subnet_id": "89fe7a9d-be92-469c-9a1e-503a39462ed1", + "ip_address": "fdd6:a1fa:cfa8:6af6:f816:3eff:fea0:8082"} + ], + "id": "352e2867-06c6-4ced-8e81-1c016991fb38", + "device_id": "8ac54799-b143-48e5-94d4-e5e989592229"}, + "vip_address": "192.168.0.137", + "id": "8ac54799-b143-48e5-94d4-e5e989592229", + "listeners": [], +} + +FAKE_LISTENER_DICT = { + 'admin_state_up': True, + 'default_pool': None, + 'id': '8dca64a2-beaa-484e-a3c8-59c9b63913e0', + 'name': 'listener1', + 'protocol': 'HTTP', + 'protocol_port': 80, + 'tenant_id': 'd22b149cee9b4eac8349c517eda00b89' +} + + + +FAKE_POOL_DICT = { + 'admin_state_up': True, + 'healthmonitor': None, + 'id': u'255c4d63-6199-4afc-abec-48c5ab46ac2e', + 'lb_algorithm': u'ROUND_ROBIN', + 'members': [], + 'name': u'pool1', + 'protocol': u'HTTP', + 'session_persistence': None, + 'tenant_id': u'd22b149cee9b4eac8349c517eda00b89' +} + + +FAKE_MEMBER_DICT = { + 'address': u'192.168.0.194', + 'admin_state_up': True, + 'id': u'30fc9549-7804-4196-bb86-8ebabc3a79e2', + 'protocol_port': 80, + 'subnet': None, + 'tenant_id': u'd22b149cee9b4eac8349c517eda00b89', + 'weight': 1 +} + + +def fake_loadbalancer_dict(listener=False, pool=False, members=False): + lb_dict = copy(FAKE_LOADBALANCER_DICT) + + if listener: + lb_dict['listeners'] = [copy(FAKE_LISTENER_DICT)] + + if pool: + if not listener: + raise Exception("Cannot create pool without a listener") + lb_dict['listeners'][0]['default_pool'] = \ + copy(FAKE_POOL_DICT) + + if members: + if not pool: + raise Exception("Cannot create member without a pool") + lb_dict['listeners'][0]['default_pool']['members'] = \ + [copy(FAKE_MEMBER_DICT)] + return lb_dict diff --git a/test/unit/test_models.py b/test/unit/test_models.py index 226b2f5..f022dbb 100644 --- a/test/unit/test_models.py +++ b/test/unit/test_models.py @@ -17,11 +17,14 @@ import textwrap +import copy import mock import netaddr + from unittest2 import TestCase from akanda.router import models +from test.unit import fakes class InterfaceModelTestCase(TestCase): @@ -360,7 +363,7 @@ class NetworkTestCase(TestCase): n = models.Network('id', None, v6_conf_service='invalid') -class ConfigurationTestCase(TestCase): +class RouterConfigurationTestCase(TestCase): def test_init_only_networks(self): subnet = dict( cidr='192.168.1.0/24', @@ -375,28 +378,28 @@ class ConfigurationTestCase(TestCase): allocations=[], subnets=[subnet]) - c = models.Configuration(dict(networks=[network])) + c = models.RouterConfiguration(dict(networks=[network])) self.assertEqual(len(c.networks), 1) self.assertEqual(c.networks[0], models.Network.from_dict(network)) def test_init_tenant_id(self): - c = models.Configuration({'tenant_id': 'abc123'}) + c = models.RouterConfiguration({'tenant_id': 'abc123'}) self.assertEqual(c.tenant_id, 'abc123') def test_no_default_v4_gateway(self): - c = models.Configuration({}) + c = models.RouterConfiguration({}) self.assertIsNone(c.default_v4_gateway) def test_valid_default_v4_gateway(self): - c = models.Configuration({'default_v4_gateway': '172.16.77.1'}) + c = models.RouterConfiguration({'default_v4_gateway': '172.16.77.1'}) self.assertEqual(c.default_v4_gateway.version, 4) self.assertEqual(str(c.default_v4_gateway), '172.16.77.1') def test_init_only_static_routes(self): routes = [('0.0.0.0/0', '192.168.1.1'), ('172.16.77.0/16', '192.168.1.254')] - c = models.Configuration(dict(networks=[], static_routes=routes)) + c = models.RouterConfiguration(dict(networks=[], static_routes=routes)) self.assertEqual(len(c.static_routes), 2) self.assertEqual( @@ -406,7 +409,7 @@ class ConfigurationTestCase(TestCase): def test_init_address_book(self): ab = {"webservers": ["192.168.57.101/32", "192.168.57.230/32"]} - c = models.Configuration(dict(networks=[], address_book=ab)) + c = models.RouterConfiguration(dict(networks=[], address_book=ab)) self.assertEqual( c.address_book.get('webservers'), models.AddressBookEntry('webservers', ab['webservers'])) @@ -414,7 +417,7 @@ class ConfigurationTestCase(TestCase): def test_init_label(self): labels = {"external": ["192.168.57.0/24"]} - c = models.Configuration(dict(networks=[], labels=labels)) + c = models.RouterConfiguration(dict(networks=[], labels=labels)) self.assertEqual( c.labels[0], models.Label('external', ['192.168.57.0/24'])) @@ -424,30 +427,30 @@ class ConfigurationTestCase(TestCase): name='theanchor', rules=[]) - c = models.Configuration(dict(networks=[], anchors=[anchor_dict])) + c = models.RouterConfiguration(dict(networks=[], anchors=[anchor_dict])) self.assertEqual(len(c.anchors), 1) def test_init_anchor(self): test_rule = dict(action='block', source='192.168.1.1/32') anchor_dict = dict(name='theanchor', rules=[test_rule]) - c = models.Configuration(dict(networks=[], anchors=[anchor_dict])) + c = models.RouterConfiguration(dict(networks=[], anchors=[anchor_dict])) self.assertEqual(len(c.anchors), 1) self.assertEqual(len(c.anchors[0].rules), 1) self.assertEqual(c.anchors[0].rules[0].action, 'block') def test_asn_default(self): - c = models.Configuration({'networks': []}) + c = models.RouterConfiguration({'networks': []}) self.assertEqual(c.asn, 64512) self.assertEqual(c.neighbor_asn, 64512) def test_asn_provided_with_neighbor_fallback(self): - c = models.Configuration({'networks': [], 'asn': 12345}) + c = models.RouterConfiguration({'networks': [], 'asn': 12345}) self.assertEqual(c.asn, 12345) self.assertEqual(c.neighbor_asn, 12345) def test_asn_provided_with_neighbor_different(self): - c = models.Configuration( + c = models.RouterConfiguration( {'networks': [], 'asn': 12, 'neighbor_asn': 34} ) self.assertEqual(c.asn, 12) @@ -463,7 +466,7 @@ class ConfigurationTestCase(TestCase): ab = {"webservers": ["192.168.57.101/32", "192.168.57.230/32"]} anchor_dict = dict(name='theanchor', rules=[rule_dict]) - c = models.Configuration( + c = models.RouterConfiguration( dict(networks=[network], anchors=[anchor_dict], address_book=ab)) errors = c.validate() @@ -517,10 +520,163 @@ class ConfigurationTestCase(TestCase): self.assertEqual(len(errors), 1) def test_to_dict(self): - c = models.Configuration({'networks': []}) + c = models.RouterConfiguration({'networks': []}) expected = dict(networks=[], address_book={}, static_routes=[], anchors=[]) self.assertEqual(c.to_dict(), expected) + + + +class LBListenerTest(TestCase): + def test_from_dict(self): + ldict = copy.copy(fakes.FAKE_LISTENER_DICT) + listener = models.Listener.from_dict(ldict) + for k in ldict.keys(): + self.assertEqual(getattr(listener, k), ldict[k]) + + def test_from_dict_with_pool(self): + ldict = copy.copy(fakes.FAKE_LISTENER_DICT) + pdict = copy.copy(fakes.FAKE_POOL_DICT) + ldict['default_pool'] = pdict + listener = models.Listener.from_dict(ldict) + keys = ldict.keys() + keys.remove('default_pool') + for k in keys: + self.assertEqual(getattr(listener, k), ldict[k]) + self.assertTrue(isinstance(listener.default_pool, models.Pool)) + + def test_to_dict(self): + ldict = copy.copy(fakes.FAKE_LISTENER_DICT) + listener = models.Listener.from_dict(ldict) + l_to_dict = listener.to_dict() + for k in ldict.keys(): + self.assertEqual(l_to_dict[k], ldict[k]) + + def test_to_dict_with_pool(self): + ldict = copy.copy(fakes.FAKE_LISTENER_DICT) + pdict = copy.copy(fakes.FAKE_POOL_DICT) + ldict['default_pool'] = pdict + listener = models.Listener.from_dict(ldict).to_dict() + self.assertEqual(listener['default_pool']['id'], pdict['id']) + + +class LBPoolTest(TestCase): + def test_from_dict(self): + pdict = copy.copy(fakes.FAKE_POOL_DICT) + pool = models.Pool.from_dict(pdict) + for k in pdict.keys(): + self.assertEqual(getattr(pool, k), pdict[k]) + + def test_from_dict_with_member(self): + pdict = copy.copy(fakes.FAKE_POOL_DICT) + mdict = copy.copy(fakes.FAKE_MEMBER_DICT) + pdict['members'] = [mdict] + pool = models.Pool.from_dict(pdict) + keys = pdict.keys() + keys.remove('members') + for k in keys: + self.assertEqual(getattr(pool, k), pdict[k]) + self.assertTrue(isinstance(pool.members[0], models.Member)) + + def test_to_dict(self): + pdict = copy.copy(fakes.FAKE_POOL_DICT) + pool = models.Pool.from_dict(pdict) + p_to_dict = pool.to_dict() + for k in pdict.keys(): + self.assertEqual(p_to_dict[k], pdict[k]) + + def test_to_dict_with_member(self): + pdict = copy.copy(fakes.FAKE_POOL_DICT) + mdict = copy.copy(fakes.FAKE_MEMBER_DICT) + pdict['members'] = [mdict] + pool = models.Pool.from_dict(pdict) + pool_to_dict = pool.to_dict() + self.assertEqual(pool_to_dict['members'][0]['id'], mdict['id']) + + +class LBMemberTest(TestCase): + def test_from_dict(self): + mdict = copy.copy(fakes.FAKE_MEMBER_DICT) + member = models.Member.from_dict(mdict) + for k in mdict.keys(): + self.assertEqual(getattr(member, k), mdict[k]) + + def test_to_dict(self): + mdict = copy.copy(fakes.FAKE_MEMBER_DICT) + member = models.Member.from_dict(mdict) + m_to_dict = member.to_dict() + for k in mdict.keys(): + self.assertEqual(m_to_dict[k], mdict[k]) + + +class LoadBalancerTest(TestCase): + def test_from_dict_lb(self): + lb_dict = fakes.fake_loadbalancer_dict() + lb = models.LoadBalancer.from_dict(lb_dict) + for k in lb_dict.keys(): + self.assertEqual(getattr(lb, k), lb_dict[k]) + + def test_from_dict_lb_listener(self): + lb_dict = fakes.fake_loadbalancer_dict(listener=True) + expected_listener_id = lb_dict['listeners'][0]['id'] + lb = models.LoadBalancer.from_dict(lb_dict) + for k in lb_dict.keys(): + self.assertEqual(getattr(lb, k), lb_dict[k]) + self.assertTrue(isinstance(lb.listeners[0], models.Listener)) + self.assertEqual(lb.listeners[0].id, expected_listener_id) + + def test_from_dict_lb_listener_pool(self): + lb_dict = fakes.fake_loadbalancer_dict(listener=True, pool=True) + expected_listener_id = lb_dict['listeners'][0]['id'] + expected_pool_id = lb_dict['listeners'][0]['default_pool']['id'] + lb = models.LoadBalancer.from_dict(lb_dict) + for k in lb_dict.keys(): + self.assertEqual(getattr(lb, k), lb_dict[k]) + self.assertTrue(isinstance(lb.listeners[0], models.Listener)) + self.assertTrue(isinstance(lb.listeners[0].default_pool, + models.Pool)) + self.assertEqual(lb.listeners[0].id, expected_listener_id) + self.assertEqual(lb.listeners[0].default_pool.id, expected_pool_id) + + def test_from_dict_lb_listener_pool_members(self): + lb_dict = fakes.fake_loadbalancer_dict(listener=True, pool=True, + members=True) + expected_listener_id = lb_dict['listeners'][0]['id'] + expected_pool_id = lb_dict['listeners'][0]['default_pool']['id'] + expected_member = lb_dict['listeners'][0]['default_pool']['members'][0] + lb = models.LoadBalancer.from_dict(lb_dict) + for k in lb_dict.keys(): + self.assertEqual(getattr(lb, k), lb_dict[k]) + self.assertTrue(isinstance(lb.listeners[0], models.Listener)) + self.assertTrue(isinstance(lb.listeners[0].default_pool, + models.Pool)) + self.assertTrue(isinstance(lb.listeners[0].default_pool.members[0], + models.Member)) + self.assertEqual(lb.listeners[0].id, expected_listener_id) + self.assertEqual(lb.listeners[0].default_pool.id, expected_pool_id) + self.assertEqual(lb.listeners[0].default_pool.members[0].id, + expected_member['id']) + + +class LoadBalancerConfigurationTest(TestCase): + def setUp(self): + super(LoadBalancerConfigurationTest, self).setUp() + self.conf_dict = fakes.fake_loadbalancer_dict( + listener=True, pool=True, members=True + ) + + def test_loadbalancer_config(self): + lb_conf = models.LoadBalancerConfiguration(self.conf_dict) + errors = lb_conf.validate() + lb_conf.to_dict() + self.assertEqual(errors, []) + + def test_loadbalancer_config_validation_failed(self): + self.conf_dict.pop('id') + lb_conf = models.LoadBalancerConfiguration({}) + errors = lb_conf.validate() + # id is required + self.assertEqual(len(errors), 1) diff --git a/test/unit/test_utils.py b/test/unit/test_utils.py index e96f0c4..7d79654 100644 --- a/test/unit/test_utils.py +++ b/test/unit/test_utils.py @@ -129,4 +129,3 @@ class ExecuteTest(TestCase): utils.execute(['/bin/ls', '/no-such-directory']) except RuntimeError as e: self.assertIn('cannot access', str(e)) -