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)) -