From 762e909cbdea06f2fdbddaa700581c33ace0b5de Mon Sep 17 00:00:00 2001 From: Sukhdev Date: Wed, 13 Aug 2014 16:37:58 -0700 Subject: [PATCH] Arista Layer 3 Sevice Plugin This sevice plugin implements routing functions on Arista HW. Change-Id: Ide411540254db015167111defee7d8c6c1c27347 Implements: blueprint arista-l3-service-plugin --- etc/neutron/plugins/ml2/ml2_conf_arista.ini | 55 +++ .../ml2/drivers/arista/arista_l3_driver.py | 457 ++++++++++++++++++ neutron/plugins/ml2/drivers/arista/config.py | 58 +++ .../plugins/ml2/drivers/arista/exceptions.py | 8 + .../ml2/drivers/arista/mechanism_arista.py | 3 + neutron/services/l3_router/l3_arista.py | 294 +++++++++++ .../drivers/arista/test_arista_l3_driver.py | 396 +++++++++++++++ 7 files changed, 1271 insertions(+) create mode 100644 neutron/plugins/ml2/drivers/arista/arista_l3_driver.py create mode 100644 neutron/services/l3_router/l3_arista.py create mode 100644 neutron/tests/unit/ml2/drivers/arista/test_arista_l3_driver.py diff --git a/etc/neutron/plugins/ml2/ml2_conf_arista.ini b/etc/neutron/plugins/ml2/ml2_conf_arista.ini index a4cfee0cd2..abaf5bc7c9 100644 --- a/etc/neutron/plugins/ml2/ml2_conf_arista.ini +++ b/etc/neutron/plugins/ml2/ml2_conf_arista.ini @@ -43,3 +43,58 @@ # # region_name = # Example: region_name = RegionOne + + +[l3_arista] + +# (StrOpt) primary host IP address. This is required field. If not set, all +# communications to Arista EOS will fail. This is the host where +# primary router is created. +# +# primary_l3_host = +# Example: primary_l3_host = 192.168.10.10 +# +# (StrOpt) Primary host username. This is required field. +# if not set, all communications to Arista EOS will fail. +# +# primary_l3_host_username = +# Example: arista_primary_l3_username = admin +# +# (StrOpt) Primary host password. This is required field. +# if not set, all communications to Arista EOS will fail. +# +# primary_l3_host_password = +# Example: primary_l3_password = my_password +# +# (StrOpt) IP address of the second Arista switch paired as +# MLAG (Multi-chassis Link Aggregation) with the first. +# This is optional field, however, if mlag_config flag is set, +# then this is a required field. If not set, all +# communications to Arista EOS will fail. If mlag_config is set +# to False, then this field is ignored +# +# seconadary_l3_host = +# Example: seconadary_l3_host = 192.168.10.20 +# +# (BoolOpt) Defines if Arista switches are configured in MLAG mode +# If yes, all L3 configuration is pushed to both switches +# automatically. If this flag is set, ensure that secondary_l3_host +# is set to the second switch's IP. +# This flag is Optional. If not set, a value of "False" is assumed. +# +# mlag_config = +# Example: mlag_config = True +# +# (BoolOpt) Defines if the router is created in default VRF or a +# a specific VRF. This is optional. +# If not set, a value of "False" is assumed. +# +# Example: use_vrf = True +# +# (IntOpt) Sync interval in seconds between Neutron plugin and EOS. +# This field defines how often the synchronization is performed. +# This is an optional field. If not set, a value of 180 seconds +# is assumed. +# +# l3_sync_interval = +# Example: l3_sync_interval = 60 diff --git a/neutron/plugins/ml2/drivers/arista/arista_l3_driver.py b/neutron/plugins/ml2/drivers/arista/arista_l3_driver.py new file mode 100644 index 0000000000..a879f1ad16 --- /dev/null +++ b/neutron/plugins/ml2/drivers/arista/arista_l3_driver.py @@ -0,0 +1,457 @@ +# Copyright 2014 Arista Networks, 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. +# +# @author: Sukhdev Kapur, Arista Networks, Inc. +# + +import hashlib +import socket +import struct + +import jsonrpclib +from oslo.config import cfg + +from neutron import context as nctx +from neutron.db import db_base_plugin_v2 +from neutron.openstack.common import log as logging +from neutron.plugins.ml2.drivers.arista import exceptions as arista_exc + +LOG = logging.getLogger(__name__) + +EOS_UNREACHABLE_MSG = _('Unable to reach EOS') +DEFAULT_VLAN = 1 +MLAG_SWITCHES = 2 +VIRTUAL_ROUTER_MAC = '00:11:22:33:44:55' +IPV4_BITS = 32 +IPV6_BITS = 128 + +router_in_vrf = { + 'router': {'create': ['vrf definition {0}', + 'rd {1}', + 'exit'], + 'delete': ['no vrf definition {0}']}, + + 'interface': {'add': ['ip routing vrf {1}', + 'vlan {0}', + 'exit', + 'interface vlan {0}', + 'vrf forwarding {1}', + 'ip address {2}'], + 'remove': ['no interface vlan {0}']}} + +router_in_default_vrf = { + 'router': {'create': [], # Place holder for now. + 'delete': []}, # Place holder for now. + + 'interface': {'add': ['ip routing', + 'vlan {0}', + 'exit', + 'interface vlan {0}', + 'ip address {2}'], + 'remove': ['no interface vlan {0}']}} + +router_in_default_vrf_v6 = { + 'router': {'create': [], + 'delete': []}, + + 'interface': {'add': ['ipv6 unicast-routing', + 'vlan {0}', + 'exit', + 'interface vlan {0}', + 'ipv6 enable', + 'ipv6 address {2}'], + 'remove': ['no interface vlan {0}']}} + +additional_cmds_for_mlag = { + 'router': {'create': ['ip virtual-router mac-address {0}'], + 'delete': ['no ip virtual-router mac-address']}, + + 'interface': {'add': ['ip virtual-router address {0}'], + 'remove': []}} + +additional_cmds_for_mlag_v6 = { + 'router': {'create': [], + 'delete': []}, + + 'interface': {'add': ['ipv6 virtual-router address {0}'], + 'remove': []}} + + +class AristaL3Driver(object): + """Wraps Arista JSON RPC. + + All communications between Neutron and EOS are over JSON RPC. + EOS - operating system used on Arista hardware + Command API - JSON RPC API provided by Arista EOS + """ + def __init__(self): + self._servers = [] + self._hosts = [] + self.interfaceDict = None + self._validate_config() + host = cfg.CONF.l3_arista.primary_l3_host + self._hosts.append(host) + self._servers.append(jsonrpclib.Server(self._eapi_host_url(host))) + self.mlag_configured = cfg.CONF.l3_arista.mlag_config + self.use_vrf = cfg.CONF.l3_arista.use_vrf + if self.mlag_configured: + host = cfg.CONF.l3_arista.secondary_l3_host + self._hosts.append(host) + self._servers.append(jsonrpclib.Server(self._eapi_host_url(host))) + self._additionalRouterCmdsDict = additional_cmds_for_mlag['router'] + self._additionalInterfaceCmdsDict = ( + additional_cmds_for_mlag['interface']) + if self.use_vrf: + self.routerDict = router_in_vrf['router'] + self.interfaceDict = router_in_vrf['interface'] + else: + self.routerDict = router_in_default_vrf['router'] + self.interfaceDict = router_in_default_vrf['interface'] + + def _eapi_host_url(self, host): + user = cfg.CONF.l3_arista.primary_l3_host_username + pwd = cfg.CONF.l3_arista.primary_l3_host_password + + eapi_server_url = ('https://%s:%s@%s/command-api' % + (user, pwd, host)) + return eapi_server_url + + def _validate_config(self): + if cfg.CONF.l3_arista.get('primary_l3_host') == '': + msg = _('Required option primary_l3_host is not set') + LOG.error(msg) + raise arista_exc.AristaSevicePluginConfigError(msg=msg) + if cfg.CONF.l3_arista.get('mlag_config'): + if cfg.CONF.l3_arista.get('use_vrf'): + #This is invalid/unsupported configuration + msg = _('VRFs are not supported MLAG config mode') + LOG.error(msg) + raise arista_exc.AristaSevicePluginConfigError(msg=msg) + if cfg.CONF.l3_arista.get('secondary_l3_host') == '': + msg = _('Required option secondary_l3_host is not set') + LOG.error(msg) + raise arista_exc.AristaSevicePluginConfigError(msg=msg) + if cfg.CONF.l3_arista.get('primary_l3_host_username') == '': + msg = _('Required option primary_l3_host_username is not set') + LOG.error(msg) + raise arista_exc.AristaSevicePluginConfigError(msg=msg) + + def create_router_on_eos(self, router_name, rdm, server): + """Creates a router on Arista HW Device. + + :param router_name: globally unique identifier for router/VRF + :param rdm: A value generated by hashing router name + :param server: Server endpoint on the Arista switch to be configured + """ + cmds = [] + rd = "%s:%s" % (rdm, rdm) + + for c in self.routerDict['create']: + cmds.append(c.format(router_name, rd)) + + if self.mlag_configured: + mac = VIRTUAL_ROUTER_MAC + for c in self._additionalRouterCmdsDict['create']: + cmds.append(c.format(mac)) + + self._run_openstack_l3_cmds(cmds, server) + + def delete_router_from_eos(self, router_name, server): + """Deletes a router from Arista HW Device. + + :param router_name: globally unique identifier for router/VRF + :param server: Server endpoint on the Arista switch to be configured + """ + cmds = [] + for c in self.routerDict['delete']: + cmds.append(c.format(router_name)) + if self.mlag_configured: + for c in self._additionalRouterCmdsDict['delete']: + cmds.append(c) + + self._run_openstack_l3_cmds(cmds, server) + + def _select_dicts(self, ipv): + if self.use_vrf: + self.interfaceDict = router_in_vrf['interface'] + else: + if ipv == 6: + #for IPv6 use IPv6 commmands + self.interfaceDict = router_in_default_vrf_v6['interface'] + self._additionalInterfaceCmdsDict = ( + additional_cmds_for_mlag_v6['interface']) + else: + self.interfaceDict = router_in_default_vrf['interface'] + self._additionalInterfaceCmdsDict = ( + additional_cmds_for_mlag['interface']) + + def add_interface_to_router(self, segment_id, + router_name, gip, router_ip, mask, server): + """Adds an interface to existing HW router on Arista HW device. + + :param segment_id: VLAN Id associated with interface that is added + :param router_name: globally unique identifier for router/VRF + :param gip: Gateway IP associated with the subnet + :param router_ip: IP address of the router + :param mask: subnet mask to be used + :param server: Server endpoint on the Arista switch to be configured + """ + + if not segment_id: + segment_id = DEFAULT_VLAN + cmds = [] + for c in self.interfaceDict['add']: + if self.mlag_configured: + ip = router_ip + else: + ip = gip + '/' + mask + cmds.append(c.format(segment_id, router_name, ip)) + if self.mlag_configured: + for c in self._additionalInterfaceCmdsDict['add']: + cmds.append(c.format(gip)) + + self._run_openstack_l3_cmds(cmds, server) + + def delete_interface_from_router(self, segment_id, router_name, server): + """Deletes an interface from existing HW router on Arista HW device. + + :param segment_id: VLAN Id associated with interface that is added + :param router_name: globally unique identifier for router/VRF + :param server: Server endpoint on the Arista switch to be configured + """ + + if not segment_id: + segment_id = DEFAULT_VLAN + cmds = [] + for c in self.interfaceDict['remove']: + cmds.append(c.format(segment_id)) + + self._run_openstack_l3_cmds(cmds, server) + + def create_router(self, context, tenant_id, router): + """Creates a router on Arista Switch. + + Deals with multiple configurations - such as Router per VRF, + a router in default VRF, Virtual Router in MLAG configurations + """ + if router: + router_name = self._arista_router_name(tenant_id, router['name']) + + rdm = str(int(hashlib.sha256(router_name).hexdigest(), + 16) % 6553) + for s in self._servers: + self.create_router_on_eos(router_name, rdm, s) + + def delete_router(self, context, tenant_id, router_id, router): + """Deletes a router from Arista Switch.""" + + if router: + for s in self._servers: + self.delete_router_from_eos(self._arista_router_name( + tenant_id, router['name']), s) + + def update_router(self, context, router_id, original_router, new_router): + """Updates a router which is already created on Arista Switch. + + TODO: (Sukhdev) - to be implemented in next release. + """ + pass + + def add_router_interface(self, context, router_info): + """Adds an interface to a router created on Arista HW router. + + This deals with both IPv6 and IPv4 configurations. + """ + if router_info: + self._select_dicts(router_info['ip_version']) + cidr = router_info['cidr'] + subnet_mask = cidr.split('/')[1] + router_name = self._arista_router_name(router_info['tenant_id'], + router_info['name']) + if self.mlag_configured: + # For MLAG, we send a specific IP address as opposed to cidr + # For now, we are using x.x.x.253 and x.x.x.254 as virtual IP + for i, server in enumerate(self._servers): + #get appropriate virtual IP address for this router + router_ip = self._get_router_ip(cidr, i, + router_info['ip_version']) + self.add_interface_to_router(router_info['seg_id'], + router_name, + router_info['gip'], + router_ip, subnet_mask, + server) + + else: + for s in self._servers: + self.add_interface_to_router(router_info['seg_id'], + router_name, + router_info['gip'], + None, subnet_mask, s) + + def remove_router_interface(self, context, router_info): + """Removes previously configured interface from router on Arista HW. + + This deals with both IPv6 and IPv4 configurations. + """ + if router_info: + router_name = self._arista_router_name(router_info['tenant_id'], + router_info['name']) + for s in self._servers: + self.delete_interface_from_router(router_info['seg_id'], + router_name, s) + + def _run_openstack_l3_cmds(self, commands, server): + """Execute/sends a CAPI (Command API) command to EOS. + + In this method, list of commands is appended with prefix and + postfix commands - to make is understandble by EOS. + + :param commands : List of command to be executed on EOS. + :param server: Server endpoint on the Arista switch to be configured + """ + command_start = ['enable', 'configure'] + command_end = ['exit'] + full_command = command_start + commands + command_end + + LOG.info(_('Executing command on Arista EOS: %s'), full_command) + + try: + # this returns array of return values for every command in + # full_command list + ret = server.runCmds(version=1, cmds=full_command) + LOG.info(_('Results of execution on Arista EOS: %s'), ret) + + except Exception: + msg = (_('Error occured while trying to execute ' + 'commands %(cmd)s on EOS %(host)s') % + {'cmd': full_command, 'host': server}) + LOG.exception(msg) + raise arista_exc.AristaServicePluginRpcError(msg=msg) + + def _arista_router_name(self, tenant_id, name): + # Use a unique name so that OpenStack created routers/SVIs + # can be distinguishged from the user created routers/SVIs + # on Arista HW. + return 'OS' + '-' + tenant_id + '-' + name + + def _get_binary_from_ipv4(self, ip_addr): + return struct.unpack("!L", socket.inet_pton(socket.AF_INET, + ip_addr))[0] + + def _get_binary_from_ipv6(self, ip_addr): + hi, lo = struct.unpack("!QQ", socket.inet_pton(socket.AF_INET6, + ip_addr)) + return (hi << 64) | lo + + def _get_ipv4_from_binary(self, bin_addr): + return socket.inet_ntop(socket.AF_INET, struct.pack("!L", bin_addr)) + + def _get_ipv6_from_binary(self, bin_addr): + hi = bin_addr >> 64 + lo = bin_addr & 0xFFFFFFFF + return socket.inet_ntop(socket.AF_INET6, struct.pack("!QQ", hi, lo)) + + def _get_router_ip(self, cidr, ip_count, ip_ver): + """ For a given IP subnet and IP version type, generate IP for router. + + This method takes the network address (cidr) and selects an + IP address that should be assigned to virtual router running + on multiple switches. It uses upper addresses in a subnet address + as IP for the router. Each instace of the router, on each switch, + requires uniqe IP address. For example in IPv4 case, on a 255 + subnet, it will pick X.X.X.254 as first addess, X.X.X.253 for next, + and so on. + """ + start_ip = MLAG_SWITCHES + ip_count + network_addr, prefix = cidr.split('/') + if ip_ver == 4: + bits = IPV4_BITS + ip = self._get_binary_from_ipv4(network_addr) + elif ip_ver == 6: + bits = IPV6_BITS + ip = self._get_binary_from_ipv6(network_addr) + + mask = (pow(2, bits) - 1) << (bits - int(prefix)) + + network_addr = ip & mask + + router_ip = pow(2, bits - int(prefix)) - start_ip + + router_ip = network_addr | router_ip + if ip_ver == 4: + return self._get_ipv4_from_binary(router_ip) + '/' + prefix + else: + return self._get_ipv6_from_binary(router_ip) + '/' + prefix + + +class NeutronNets(db_base_plugin_v2.NeutronDbPluginV2): + """Access to Neutron DB. + + Provides access to the Neutron Data bases for all provisioned + networks as well ports. This data is used during the synchronization + of DB between ML2 Mechanism Driver and Arista EOS + Names of the networks and ports are not stored in Arista repository + They are pulled from Neutron DB. + """ + + def __init__(self): + self.admin_ctx = nctx.get_admin_context() + + def get_all_networks_for_tenant(self, tenant_id): + filters = {'tenant_id': [tenant_id]} + return super(NeutronNets, + self).get_networks(self.admin_ctx, filters=filters) or [] + + def get_all_ports_for_tenant(self, tenant_id): + filters = {'tenant_id': [tenant_id]} + return super(NeutronNets, + self).get_ports(self.admin_ctx, filters=filters) or [] + + def _get_network(self, tenant_id, network_id): + filters = {'tenant_id': [tenant_id], + 'id': [network_id]} + return super(NeutronNets, + self).get_networks(self.admin_ctx, filters=filters) or [] + + def get_subnet_info(self, subnet_id): + subnet = self.get_subnet(subnet_id) + return subnet + + def get_subnet_ip_version(self, subnet_id): + subnet = self.get_subnet(subnet_id) + return subnet['ip_version'] + + def get_subnet_gateway_ip(self, subnet_id): + subnet = self.get_subnet(subnet_id) + return subnet['gateway_ip'] + + def get_subnet_cidr(self, subnet_id): + subnet = self.get_subnet(subnet_id) + return subnet['cidr'] + + def get_network_id(self, subnet_id): + subnet = self.get_subnet(subnet_id) + return subnet['network_id'] + + def get_network_id_from_port_id(self, port_id): + port = self.get_port(port_id) + return port['network_id'] + + def get_subnet(self, subnet_id): + return super(NeutronNets, + self).get_subnet(self.admin_ctx, subnet_id) or [] + + def get_port(self, port_id): + return super(NeutronNets, + self).get_port(self.admin_ctx, port_id) or [] diff --git a/neutron/plugins/ml2/drivers/arista/config.py b/neutron/plugins/ml2/drivers/arista/config.py index 2f968c874d..03c695acb6 100644 --- a/neutron/plugins/ml2/drivers/arista/config.py +++ b/neutron/plugins/ml2/drivers/arista/config.py @@ -67,4 +67,62 @@ ARISTA_DRIVER_OPTS = [ '"RegionOne" is assumed.')) ] + +""" Arista L3 Service Plugin specific configuration knobs. + +Following are user configurable options for Arista L3 plugin +driver. The eapi_username, eapi_password, and eapi_host are +required options. +""" + +ARISTA_L3_PLUGIN = [ + cfg.StrOpt('primary_l3_host_username', + default='', + help=_('Username for Arista EOS. This is required field. ' + 'If not set, all communications to Arista EOS ' + 'will fail')), + cfg.StrOpt('primary_l3_host_password', + default='', + secret=True, # do not expose value in the logs + help=_('Password for Arista EOS. This is required field. ' + 'If not set, all communications to Arista EOS ' + 'will fail')), + cfg.StrOpt('primary_l3_host', + default='', + help=_('Arista EOS IP address. This is required field. ' + 'If not set, all communications to Arista EOS ' + 'will fail')), + cfg.StrOpt('secondary_l3_host', + default='', + help=_('Arista EOS IP address for second Switch MLAGed with ' + 'the first one. This an optional field, however, if ' + 'mlag_config flag is set, then this is required. ' + 'If not set, all communications to Arista EOS ' + 'will fail')), + cfg.BoolOpt('mlag_config', + default=False, + help=_('This flag is used indicate if Arista Switches are ' + 'configured in MLAG mode. If yes, all L3 config ' + 'is pushed to both the switches automatically. ' + 'If this flag is set to True, ensure to specify IP ' + 'addresses of both switches. ' + 'This is optional. If not set, a value of "False" ' + 'is assumed.')), + cfg.BoolOpt('use_vrf', + default=False, + help=_('A "True" value for this flag indicates to create a ' + 'router in VRF. If not set, all routers are created ' + 'in default VRF.' + 'This is optional. If not set, a value of "False" ' + 'is assumed.')), + cfg.IntOpt('l3_sync_interval', + default=180, + help=_('Sync interval in seconds between L3 Service plugin ' + 'and EOS. This interval defines how often the ' + 'synchronization is performed. This is an optional ' + 'field. If not set, a value of 180 seconds is assumed')) +] + +cfg.CONF.register_opts(ARISTA_L3_PLUGIN, "l3_arista") + cfg.CONF.register_opts(ARISTA_DRIVER_OPTS, "ml2_arista") diff --git a/neutron/plugins/ml2/drivers/arista/exceptions.py b/neutron/plugins/ml2/drivers/arista/exceptions.py index b3dae3dae2..73802c2f7c 100644 --- a/neutron/plugins/ml2/drivers/arista/exceptions.py +++ b/neutron/plugins/ml2/drivers/arista/exceptions.py @@ -25,3 +25,11 @@ class AristaRpcError(exceptions.NeutronException): class AristaConfigError(exceptions.NeutronException): message = _('%(msg)s') + + +class AristaServicePluginRpcError(exceptions.NeutronException): + message = _('%(msg)s') + + +class AristaSevicePluginConfigError(exceptions.NeutronException): + message = _('%(msg)s') diff --git a/neutron/plugins/ml2/drivers/arista/mechanism_arista.py b/neutron/plugins/ml2/drivers/arista/mechanism_arista.py index 0e9c5b52b5..b52c98bce5 100644 --- a/neutron/plugins/ml2/drivers/arista/mechanism_arista.py +++ b/neutron/plugins/ml2/drivers/arista/mechanism_arista.py @@ -29,6 +29,7 @@ from neutron.plugins.ml2.drivers.arista import exceptions as arista_exc LOG = logging.getLogger(__name__) EOS_UNREACHABLE_MSG = _('Unable to reach EOS') +DEFAULT_VLAN = 1 class AristaRPCWrapper(object): @@ -223,6 +224,8 @@ class AristaRPCWrapper(object): except KeyError: append_cmd('network id %s' % network['network_id']) # Enter segment mode without exiting out of network mode + if not network['segmentation_id']: + network['segmentation_id'] = DEFAULT_VLAN append_cmd('segment 1 type vlan id %d' % network['segmentation_id']) cmds.extend(self._get_exit_mode_cmds(['segment', 'network', 'tenant'])) diff --git a/neutron/services/l3_router/l3_arista.py b/neutron/services/l3_router/l3_arista.py new file mode 100644 index 0000000000..82200b2ed4 --- /dev/null +++ b/neutron/services/l3_router/l3_arista.py @@ -0,0 +1,294 @@ +# Copyright 2014 Arista Networks, 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. +# +# @author: Sukhdev Kapur, Arista Networks, Inc. +# + +import copy +import threading + +from oslo.config import cfg + +from neutron.api.rpc.agentnotifiers import l3_rpc_agent_api +from neutron.common import constants as q_const +from neutron.common import log +from neutron.common import rpc as q_rpc +from neutron.common import topics +from neutron import context as nctx +from neutron.db import db_base_plugin_v2 +from neutron.db import extraroute_db +from neutron.db import l3_agentschedulers_db +from neutron.db import l3_gwmode_db +from neutron.db import l3_rpc_base +from neutron.openstack.common import excutils +from neutron.openstack.common import log as logging +from neutron.plugins.common import constants +from neutron.plugins.ml2.driver_context import NetworkContext # noqa +from neutron.plugins.ml2.drivers.arista.arista_l3_driver import AristaL3Driver # noqa +from neutron.plugins.ml2.drivers.arista.arista_l3_driver import NeutronNets # noqa + +LOG = logging.getLogger(__name__) + + +class AristaL3ServicePluginRpcCallbacks(q_rpc.RpcCallback, + l3_rpc_base.L3RpcCallbackMixin): + + RPC_API_VERSION = '1.2' + + +class AristaL3ServicePlugin(db_base_plugin_v2.NeutronDbPluginV2, + extraroute_db.ExtraRoute_db_mixin, + l3_gwmode_db.L3_NAT_db_mixin, + l3_agentschedulers_db.L3AgentSchedulerDbMixin): + + """Implements L3 Router service plugin for Arista hardware. + + Creates routers in Arista hardware, manages them, adds/deletes interfaces + to the routes. + """ + + supported_extension_aliases = ["router", "ext-gw-mode", + "extraroute"] + + def __init__(self, driver=None): + + self.driver = driver or AristaL3Driver() + self.ndb = NeutronNets() + self.setup_rpc() + self.sync_timeout = cfg.CONF.l3_arista.l3_sync_interval + self.sync_lock = threading.Lock() + self._synchronization_thread() + + def setup_rpc(self): + # RPC support + self.topic = topics.L3PLUGIN + self.conn = q_rpc.create_connection(new=True) + self.agent_notifiers.update( + {q_const.AGENT_TYPE_L3: l3_rpc_agent_api.L3AgentNotifyAPI()}) + self.endpoints = [AristaL3ServicePluginRpcCallbacks()] + self.conn.create_consumer(self.topic, self.endpoints, + fanout=False) + self.conn.consume_in_threads() + + def get_plugin_type(self): + return constants.L3_ROUTER_NAT + + def get_plugin_description(self): + """Returns string description of the plugin.""" + return ("Arista L3 Router Service Plugin for Arista Hardware " + "based routing") + + def _synchronization_thread(self): + with self.sync_lock: + self.synchronize() + + self.timer = threading.Timer(self.sync_timeout, + self._synchronization_thread) + self.timer.start() + + def stop_synchronization_thread(self): + if self.timer: + self.timer.cancel() + self.timer = None + + @log.log + def create_router(self, context, router): + """Create a new router entry in DB, and create it Arista HW.""" + + tenant_id = self._get_tenant_id_for_create(context, router['router']) + + # Add router to the DB + with context.session.begin(subtransactions=True): + new_router = super(AristaL3ServicePlugin, self).create_router( + context, + router) + # create router on the Arista Hw + try: + self.driver.create_router(context, tenant_id, new_router) + return new_router + except Exception: + with excutils.save_and_reraise_exception(): + LOG.error(_("Error creating router on Arista HW " + "router=%s ") % new_router) + super(AristaL3ServicePlugin, self).delete_router(context, + new_router['id']) + + @log.log + def update_router(self, context, router_id, router): + """Update an existing router in DB, and update it in Arista HW.""" + + with context.session.begin(subtransactions=True): + # Read existing router record from DB + original_router = super(AristaL3ServicePlugin, self).get_router( + context, router_id) + # Update router DB + new_router = super(AristaL3ServicePlugin, self).update_router( + context, router_id, router) + + # Modify router on the Arista Hw + try: + self.driver.update_router(context, router_id, + original_router, new_router) + return new_router + except Exception: + LOG.error(_("Error updating router on Arista HW " + "router=%s ") % new_router) + + @log.log + def delete_router(self, context, router_id): + """Delete an existing router from Arista HW as well as from the DB.""" + + router = super(AristaL3ServicePlugin, self).get_router(context, + router_id) + tenant_id = router['tenant_id'] + + # Delete router on the Arista Hw + try: + self.driver.delete_router(context, tenant_id, router_id, router) + except Exception as e: + LOG.error(_("Error deleting router on Arista HW " + "router %(r)s exception=%(e)s") % + {'r': router, 'e': e}) + + with context.session.begin(subtransactions=True): + super(AristaL3ServicePlugin, self).delete_router(context, + router_id) + + @log.log + def add_router_interface(self, context, router_id, interface_info): + """Add a subnet of a network to an existing router.""" + + new_router = super(AristaL3ServicePlugin, self).add_router_interface( + context, router_id, interface_info) + + # Get network info for the subnet that is being added to the router. + # Check if the interface information is by port-id or subnet-id + add_by_port, add_by_sub = self._validate_interface_info(interface_info) + if add_by_sub: + subnet = self.get_subnet(context, interface_info['subnet_id']) + elif add_by_port: + port = self.get_port(context, interface_info['port_id']) + subnet_id = port['fixed_ips'][0]['subnet_id'] + subnet = self.get_subnet(context, subnet_id) + network_id = subnet['network_id'] + + # To create SVI's in Arista HW, the segmentation Id is required + # for this network. + ml2_db = NetworkContext(self, context, {'id': network_id}) + seg_id = ml2_db.network_segments[0]['segmentation_id'] + + # Package all the info needed for Hw programming + router = super(AristaL3ServicePlugin, self).get_router(context, + router_id) + router_info = copy.deepcopy(new_router) + router_info['seg_id'] = seg_id + router_info['name'] = router['name'] + router_info['cidr'] = subnet['cidr'] + router_info['gip'] = subnet['gateway_ip'] + router_info['ip_version'] = subnet['ip_version'] + + try: + self.driver.add_router_interface(context, router_info) + return new_router + except Exception: + with excutils.save_and_reraise_exception(): + LOG.error(_("Error Adding subnet %(subnet)s to " + "router %(router_id)s on Arista HW") % + {'subnet': subnet, 'router_id': router_id}) + super(AristaL3ServicePlugin, self).remove_router_interface( + context, + router_id, + interface_info) + + @log.log + def remove_router_interface(self, context, router_id, interface_info): + """Remove a subnet of a network from an existing router.""" + + new_router = ( + super(AristaL3ServicePlugin, self).remove_router_interface( + context, router_id, interface_info)) + + # Get network information of the subnet that is being removed + subnet = self.get_subnet(context, new_router['subnet_id']) + network_id = subnet['network_id'] + + # For SVI removal from Arista HW, segmentation ID is needed + ml2_db = NetworkContext(self, context, {'id': network_id}) + seg_id = ml2_db.network_segments[0]['segmentation_id'] + + router = super(AristaL3ServicePlugin, self).get_router(context, + router_id) + router_info = copy.deepcopy(new_router) + router_info['seg_id'] = seg_id + router_info['name'] = router['name'] + + try: + self.driver.remove_router_interface(context, router_info) + return new_router + except Exception as exc: + LOG.error(_("Error removing interface %(interface)s from " + "router %(router_id)s on Arista HW" + "Exception =(exc)s") % {'interface': interface_info, + 'router_id': router_id, + 'exc': exc}) + + def synchronize(self): + """Synchronizes Router DB from Neturon DB with EOS. + + Walks through the Neturon Db and ensures that all the routers + created in Netuton DB match with EOS. After creating appropriate + routers, it ensures to add interfaces as well. + Uses idempotent properties of EOS configuration, which means + same commands can be repeated. + """ + LOG.info(_('Syncing Neutron Router DB <-> EOS')) + ctx = nctx.get_admin_context() + + routers = super(AristaL3ServicePlugin, self).get_routers(ctx) + for r in routers: + tenant_id = r['tenant_id'] + ports = self.ndb.get_all_ports_for_tenant(tenant_id) + + try: + self.driver.create_router(self, tenant_id, r) + + except Exception: + continue + + # Figure out which interfaces are added to this router + for p in ports: + if p['device_id'] == r['id']: + net_id = p['network_id'] + subnet_id = p['fixed_ips'][0]['subnet_id'] + subnet = self.ndb.get_subnet_info(subnet_id) + ml2_db = NetworkContext(self, ctx, {'id': net_id}) + seg_id = ml2_db.network_segments[0]['segmentation_id'] + + r['seg_id'] = seg_id + r['cidr'] = subnet['cidr'] + r['gip'] = subnet['gateway_ip'] + r['ip_version'] = subnet['ip_version'] + + try: + self.driver.add_router_interface(self, r) + except Exception: + LOG.error(_("Error Adding interface %(subnet_id)s to " + "router %(router_id)s on Arista HW") % + {'subnet_id': subnet_id, + 'router_id': r}) + + def _validate_interface_info(self, interface_info): + port_id_specified = interface_info and 'port_id' in interface_info + subnet_id_specified = interface_info and 'subnet_id' in interface_info + return port_id_specified, subnet_id_specified diff --git a/neutron/tests/unit/ml2/drivers/arista/test_arista_l3_driver.py b/neutron/tests/unit/ml2/drivers/arista/test_arista_l3_driver.py new file mode 100644 index 0000000000..7b0649e2f4 --- /dev/null +++ b/neutron/tests/unit/ml2/drivers/arista/test_arista_l3_driver.py @@ -0,0 +1,396 @@ +# Copyright (c) 2013 OpenStack Foundation +# +# 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. +# +# @author: Sukhdev Kapur, Arista Networks, Inc. +# + + +import mock +from oslo.config import cfg + +from neutron.plugins.ml2.drivers.arista import arista_l3_driver as arista +from neutron.tests import base + + +def setup_arista_config(value='', vrf=False, mlag=False): + cfg.CONF.set_override('primary_l3_host', value, "l3_arista") + cfg.CONF.set_override('primary_l3_host_username', value, "l3_arista") + if vrf: + cfg.CONF.set_override('use_vrf', value, "l3_arista") + if mlag: + cfg.CONF.set_override('secondary_l3_host', value, "l3_arista") + cfg.CONF.set_override('mlag_config', value, "l3_arista") + + +class AristaL3DriverTestCasesDefaultVrf(base.BaseTestCase): + """Test cases to test the RPC between Arista Driver and EOS. + + Tests all methods used to send commands between Arista L3 Driver and EOS + to program routing functions in Default VRF. + """ + + def setUp(self): + super(AristaL3DriverTestCasesDefaultVrf, self).setUp() + setup_arista_config('value') + self.drv = arista.AristaL3Driver() + self.drv._servers = [] + self.drv._servers.append(mock.MagicMock()) + + def test_no_exception_on_correct_configuration(self): + self.assertIsNotNone(self.drv) + + def test_create_router_on_eos(self): + router_name = 'test-router-1' + route_domain = '123:123' + + self.drv.create_router_on_eos(router_name, route_domain, + self.drv._servers[0]) + cmds = ['enable', 'configure', 'exit'] + + self.drv._servers[0].runCmds.assert_called_once_with(version=1, + cmds=cmds) + + def test_delete_router_from_eos(self): + router_name = 'test-router-1' + + self.drv.delete_router_from_eos(router_name, self.drv._servers[0]) + cmds = ['enable', 'configure', 'exit'] + + self.drv._servers[0].runCmds.assert_called_once_with(version=1, + cmds=cmds) + + def test_add_interface_to_router_on_eos(self): + router_name = 'test-router-1' + segment_id = '123' + router_ip = '10.10.10.10' + gw_ip = '10.10.10.1' + mask = '255.255.255.0' + + self.drv.add_interface_to_router(segment_id, router_name, gw_ip, + router_ip, mask, self.drv._servers[0]) + cmds = ['enable', 'configure', 'ip routing', + 'vlan %s' % segment_id, 'exit', + 'interface vlan %s' % segment_id, + 'ip address %s/%s' % (gw_ip, mask), 'exit'] + + self.drv._servers[0].runCmds.assert_called_once_with(version=1, + cmds=cmds) + + def test_delete_interface_from_router_on_eos(self): + router_name = 'test-router-1' + segment_id = '123' + + self.drv.delete_interface_from_router(segment_id, router_name, + self.drv._servers[0]) + cmds = ['enable', 'configure', 'no interface vlan %s' % segment_id, + 'exit'] + + self.drv._servers[0].runCmds.assert_called_once_with(version=1, + cmds=cmds) + + +class AristaL3DriverTestCasesUsingVRFs(base.BaseTestCase): + """Test cases to test the RPC between Arista Driver and EOS. + + Tests all methods used to send commands between Arista L3 Driver and EOS + to program routing functions using multiple VRFs. + Note that the configuration commands are different when VRFs are used. + """ + + def setUp(self): + super(AristaL3DriverTestCasesUsingVRFs, self).setUp() + setup_arista_config('value', vrf=True) + self.drv = arista.AristaL3Driver() + self.drv._servers = [] + self.drv._servers.append(mock.MagicMock()) + + def test_no_exception_on_correct_configuration(self): + self.assertIsNotNone(self.drv) + + def test_create_router_on_eos(self): + max_vrfs = 5 + routers = ['testRouter-%s' % n for n in range(max_vrfs)] + domains = ['10%s' % n for n in range(max_vrfs)] + + for (r, d) in zip(routers, domains): + self.drv.create_router_on_eos(r, d, self.drv._servers[0]) + + cmds = ['enable', 'configure', + 'vrf definition %s' % r, + 'rd %(rd)s:%(rd)s' % {'rd': d}, 'exit', 'exit'] + + self.drv._servers[0].runCmds.assert_called_with(version=1, + cmds=cmds) + + def test_delete_router_from_eos(self): + max_vrfs = 5 + routers = ['testRouter-%s' % n for n in range(max_vrfs)] + + for r in routers: + self.drv.delete_router_from_eos(r, self.drv._servers[0]) + cmds = ['enable', 'configure', 'no vrf definition %s' % r, + 'exit'] + + self.drv._servers[0].runCmds.assert_called_with(version=1, + cmds=cmds) + + def test_add_interface_to_router_on_eos(self): + router_name = 'test-router-1' + segment_id = '123' + router_ip = '10.10.10.10' + gw_ip = '10.10.10.1' + mask = '255.255.255.0' + + self.drv.add_interface_to_router(segment_id, router_name, gw_ip, + router_ip, mask, self.drv._servers[0]) + cmds = ['enable', 'configure', + 'ip routing vrf %s' % router_name, + 'vlan %s' % segment_id, 'exit', + 'interface vlan %s' % segment_id, + 'vrf forwarding %s' % router_name, + 'ip address %s/%s' % (gw_ip, mask), 'exit'] + + self.drv._servers[0].runCmds.assert_called_once_with(version=1, + cmds=cmds) + + def test_delete_interface_from_router_on_eos(self): + router_name = 'test-router-1' + segment_id = '123' + + self.drv.delete_interface_from_router(segment_id, router_name, + self.drv._servers[0]) + cmds = ['enable', 'configure', 'no interface vlan %s' % segment_id, + 'exit'] + + self.drv._servers[0].runCmds.assert_called_once_with(version=1, + cmds=cmds) + + +class AristaL3DriverTestCasesMlagConfig(base.BaseTestCase): + """Test cases to test the RPC between Arista Driver and EOS. + + Tests all methods used to send commands between Arista L3 Driver and EOS + to program routing functions in Default VRF using MLAG configuration. + MLAG configuration means that the commands will be sent to both + primary and secondary Arista Switches. + """ + + def setUp(self): + super(AristaL3DriverTestCasesMlagConfig, self).setUp() + setup_arista_config('value', mlag=True) + self.drv = arista.AristaL3Driver() + self.drv._servers = [] + self.drv._servers.append(mock.MagicMock()) + self.drv._servers.append(mock.MagicMock()) + + def test_no_exception_on_correct_configuration(self): + self.assertIsNotNone(self.drv) + + def test_create_router_on_eos(self): + router_name = 'test-router-1' + route_domain = '123:123' + router_mac = '00:11:22:33:44:55' + + for s in self.drv._servers: + self.drv.create_router_on_eos(router_name, route_domain, s) + cmds = ['enable', 'configure', + 'ip virtual-router mac-address %s' % router_mac, 'exit'] + + s.runCmds.assert_called_with(version=1, cmds=cmds) + + def test_delete_router_from_eos(self): + router_name = 'test-router-1' + + for s in self.drv._servers: + self.drv.delete_router_from_eos(router_name, s) + cmds = ['enable', 'configure', + 'no ip virtual-router mac-address', 'exit'] + + s.runCmds.assert_called_once_with(version=1, cmds=cmds) + + def test_add_interface_to_router_on_eos(self): + router_name = 'test-router-1' + segment_id = '123' + router_ip = '10.10.10.10' + gw_ip = '10.10.10.1' + mask = '255.255.255.0' + + for s in self.drv._servers: + self.drv.add_interface_to_router(segment_id, router_name, gw_ip, + router_ip, mask, s) + cmds = ['enable', 'configure', 'ip routing', + 'vlan %s' % segment_id, 'exit', + 'interface vlan %s' % segment_id, + 'ip address %s' % router_ip, + 'ip virtual-router address %s' % gw_ip, 'exit'] + + s.runCmds.assert_called_once_with(version=1, cmds=cmds) + + def test_delete_interface_from_router_on_eos(self): + router_name = 'test-router-1' + segment_id = '123' + + for s in self.drv._servers: + self.drv.delete_interface_from_router(segment_id, router_name, s) + + cmds = ['enable', 'configure', 'no interface vlan %s' % segment_id, + 'exit'] + + s.runCmds.assert_called_once_with(version=1, cmds=cmds) + + +class AristaL3DriverTestCases_v4(base.BaseTestCase): + """Test cases to test the RPC between Arista Driver and EOS. + + Tests all methods used to send commands between Arista L3 Driver and EOS + to program routing functions in Default VRF using IPv4. + """ + + def setUp(self): + super(AristaL3DriverTestCases_v4, self).setUp() + setup_arista_config('value') + self.drv = arista.AristaL3Driver() + self.drv._servers = [] + self.drv._servers.append(mock.MagicMock()) + + def test_no_exception_on_correct_configuration(self): + self.assertIsNotNone(self.drv) + + def test_add_v4_interface_to_router(self): + gateway_ip = '10.10.10.1' + cidrs = ['10.10.10.0/24', '10.11.11.0/24'] + + # Add couple of IPv4 subnets to router + for cidr in cidrs: + router = {'name': 'test-router-1', + 'tenant_id': 'ten-a', + 'seg_id': '123', + 'cidr': "%s" % cidr, + 'gip': "%s" % gateway_ip, + 'ip_version': 4} + + self.assertFalse(self.drv.add_router_interface(None, router)) + + def test_delete_v4_interface_from_router(self): + gateway_ip = '10.10.10.1' + cidrs = ['10.10.10.0/24', '10.11.11.0/24'] + + # remove couple of IPv4 subnets from router + for cidr in cidrs: + router = {'name': 'test-router-1', + 'tenant_id': 'ten-a', + 'seg_id': '123', + 'cidr': "%s" % cidr, + 'gip': "%s" % gateway_ip, + 'ip_version': 4} + + self.assertFalse(self.drv.remove_router_interface(None, router)) + + +class AristaL3DriverTestCases_v6(base.BaseTestCase): + """Test cases to test the RPC between Arista Driver and EOS. + + Tests all methods used to send commands between Arista L3 Driver and EOS + to program routing functions in Default VRF using IPv6. + """ + + def setUp(self): + super(AristaL3DriverTestCases_v6, self).setUp() + setup_arista_config('value') + self.drv = arista.AristaL3Driver() + self.drv._servers = [] + self.drv._servers.append(mock.MagicMock()) + + def test_no_exception_on_correct_configuration(self): + self.assertIsNotNone(self.drv) + + def test_add_v6_interface_to_router(self): + gateway_ip = '3FFE::1' + cidrs = ['3FFE::/16', '2001::/16'] + + # Add couple of IPv6 subnets to router + for cidr in cidrs: + router = {'name': 'test-router-1', + 'tenant_id': 'ten-a', + 'seg_id': '123', + 'cidr': "%s" % cidr, + 'gip': "%s" % gateway_ip, + 'ip_version': 6} + + self.assertFalse(self.drv.add_router_interface(None, router)) + + def test_delete_v6_interface_from_router(self): + gateway_ip = '3FFE::1' + cidrs = ['3FFE::/16', '2001::/16'] + + # remove couple of IPv6 subnets from router + for cidr in cidrs: + router = {'name': 'test-router-1', + 'tenant_id': 'ten-a', + 'seg_id': '123', + 'cidr': "%s" % cidr, + 'gip': "%s" % gateway_ip, + 'ip_version': 6} + + self.assertFalse(self.drv.remove_router_interface(None, router)) + + +class AristaL3DriverTestCases_MLAG_v6(base.BaseTestCase): + """Test cases to test the RPC between Arista Driver and EOS. + + Tests all methods used to send commands between Arista L3 Driver and EOS + to program routing functions in Default VRF on MLAG'ed switches using IPv6. + """ + + def setUp(self): + super(AristaL3DriverTestCases_MLAG_v6, self).setUp() + setup_arista_config('value', mlag=True) + self.drv = arista.AristaL3Driver() + self.drv._servers = [] + self.drv._servers.append(mock.MagicMock()) + self.drv._servers.append(mock.MagicMock()) + + def test_no_exception_on_correct_configuration(self): + self.assertIsNotNone(self.drv) + + def test_add_v6_interface_to_router(self): + gateway_ip = '3FFE::1' + cidrs = ['3FFE::/16', '2001::/16'] + + # Add couple of IPv6 subnets to router + for cidr in cidrs: + router = {'name': 'test-router-1', + 'tenant_id': 'ten-a', + 'seg_id': '123', + 'cidr': "%s" % cidr, + 'gip': "%s" % gateway_ip, + 'ip_version': 6} + + self.assertFalse(self.drv.add_router_interface(None, router)) + + def test_delete_v6_interface_from_router(self): + gateway_ip = '3FFE::1' + cidrs = ['3FFE::/16', '2001::/16'] + + # remove couple of IPv6 subnets from router + for cidr in cidrs: + router = {'name': 'test-router-1', + 'tenant_id': 'ten-a', + 'seg_id': '123', + 'cidr': "%s" % cidr, + 'gip': "%s" % gateway_ip, + 'ip_version': 6} + + self.assertFalse(self.drv.remove_router_interface(None, router))