networking-arista/networking_arista/l3Plugin/arista_l3_driver.py

753 lines
31 KiB
Python

# 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.
import hashlib
import socket
import struct
from neutron.plugins.ml2.driver_context import NetworkContext # noqa
from neutron_lib.callbacks import events
from neutron_lib.callbacks import registry
from neutron_lib.callbacks import resources
from neutron_lib import constants as const
from neutron_lib import context as nctx
from oslo_config import cfg
from oslo_log import log as logging
from networking_arista._i18n import _, _LI
from networking_arista.common import api
from networking_arista.common import db_lib
from networking_arista.common import exceptions as arista_exc
LOG = logging.getLogger(__name__)
cfg.CONF.import_group('l3_arista', 'networking_arista.common.config')
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
# This string-format-at-a-distance confuses pylint :(
# pylint: disable=too-many-format-args
router_in_vrf_v1 = {
'router': {'create': ['vrf definition {0}',
'rd {1}',
'exit',
'ip routing vrf {0}'],
'delete': ['no vrf definition {0}']},
'interface': {'add': ['vlan {0}',
'exit',
'interface vlan {0}',
'vrf forwarding {1}',
'ip address {2}'],
'remove': ['no interface vlan {0}']}}
router_in_vrf_v2 = {
'router': {'create': ['vrf instance {0}',
'rd {1}',
'exit',
'ip routing vrf {0}'],
'delete': ['no vrf instance {0}']},
'interface': {'add': ['vlan {0}',
'exit',
'interface vlan {0}',
'vrf {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': []},
'interface': {'add': ['ip virtual-router address {0}'],
'remove': []}}
additional_cmds_for_mlag_v6 = {
'router': {'create': [],
'delete': []},
'interface': {'add': ['ipv6 virtual-router address {0}'],
'remove': []}}
additional_cmds_for_default_route = {
'add': ['ip route vrf {0} 0.0.0.0/0 {1}'],
# Remove is used when updating a network to remove default gateway
# when deleting an interface we don't need to delete the route
'remove': ['no ip route vrf {0} 0.0.0.0/0']}
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(self._make_eapi_client(host))
self._mlag_configured = cfg.CONF.l3_arista.mlag_config
self._vrf_syntax_v2_supported = None
self._router_in_vrf = router_in_vrf_v2
self._use_vrf = cfg.CONF.l3_arista.use_vrf
self._vrf_default_route = False
if self._use_vrf:
self._vrf_default_route = cfg.CONF.l3_arista.vrf_default_route
if self._vrf_default_route:
# only subscribe for events if vrf default route is enabled
self.subscribe()
if self._mlag_configured:
host = cfg.CONF.l3_arista.secondary_l3_host
self._hosts.append(host)
self._servers.append(self._make_eapi_client(host))
self._additionalRouterCmdsDict = additional_cmds_for_mlag['router']
self._additionalInterfaceCmdsDict = (
additional_cmds_for_mlag['interface'])
if self._use_vrf:
self.routerDict = self._router_in_vrf['router']
self._update_vrf_commands(keep_alive=False)
self._interfaceDict = self._router_in_vrf['interface']
else:
self.routerDict = router_in_default_vrf['router']
self._interfaceDict = router_in_default_vrf['interface']
self._enable_cleanup = cfg.CONF.l3_arista.enable_cleanup
self._protected_vlans = self._parse_protected_vlans(
cfg.CONF.l3_arista.protected_vlans)
def subscribe(self):
# Subscribe to the events related to networks and subnets
registry.subscribe(self.update_subnet, resources.SUBNET,
events.AFTER_UPDATE)
registry.subscribe(self.update_network, resources.NETWORK,
events.AFTER_UPDATE)
def update_subnet(self, resource, event, trigger, payload):
subnet_info = payload.latest_state
if subnet_info['ip_version'] == 6:
LOG.info('IPv6 networks not supported with L3 plugin')
return
ctx = nctx.get_admin_context()
ml2_db = NetworkContext(self, ctx, {'id': subnet_info['network_id']})
seg_id = ml2_db.network_segments[0]['segmentation_id']
router_info = db_lib.get_subnet_gateway_ipv4(subnet_info['id'])
if router_info:
router_info['seg_id'] = seg_id
router_name = self._arista_router_name(router_info['id'],
router_info['name'])
self._delete_default_gateway(router_name)
self.add_router_interface(ctx, router_info)
self._setup_default_gateway(router_info)
def _reset_network_default_route(self, network_id):
router_info = self._prepare_network_default_gateway(network_id)
if router_info:
router_name = self._arista_router_name(router_info['id'],
router_info['name'])
self._delete_default_gateway(router_name)
self._add_network_default_gateway(network_id)
def update_network(self, resource, event, trigger, payload):
self._reset_network_default_route(payload.resource_id)
def _prepare_network_default_gateway(self, network_id):
router_info = db_lib.get_network_gateway_ipv4(network_id)
if not router_info:
return
ip_version = router_info['ip_version']
if ip_version == 6:
LOG.info('IPv6 networks not supported with L3 plugin')
return
ctx = nctx.get_admin_context()
ml2_db = NetworkContext(self, ctx, {'id': network_id})
seg_id = ml2_db.network_segments[0]['segmentation_id']
router_info['seg_id'] = seg_id
return router_info
def _add_network_default_gateway(self, network_id):
router_info = self._prepare_network_default_gateway(network_id)
if router_info:
ctx = nctx.get_admin_context()
self.add_router_interface(ctx, router_info)
self._setup_default_gateway(router_info)
@staticmethod
def _raise_invalid_protected_vlans(vlan_string):
msg = '%s is not a valid vlan or vlan range' % vlan_string
LOG.error(msg)
raise arista_exc.AristaServicePluginConfigError(msg=msg)
def _parse_protected_vlans(self, vlan_strings):
# VLAN 1 is always protected as it exists by default on EOS
vlans = set([1])
for vlan_string in vlan_strings:
vlan_parsed = vlan_string.split(':', 2)
if len(vlan_parsed) > 2:
self._raise_invalid_protected_vlans(vlan_string)
try:
min_vlan = int(vlan_parsed[0])
except ValueError:
self._raise_invalid_protected_vlans(vlan_string)
try:
max_vlan = int(vlan_parsed[-1])
except ValueError:
self._raise_invalid_protected_vlans(vlan_string)
if not (const.MIN_VLAN_TAG <= min_vlan <=
max_vlan <= const.MAX_VLAN_TAG):
self._raise_invalid_protected_vlans(vlan_string)
vlans.update(range(min_vlan, max_vlan + 1))
return vlans
@staticmethod
def _make_eapi_client(host):
return api.EAPIClient(
host,
username=cfg.CONF.l3_arista.primary_l3_host_username,
password=cfg.CONF.l3_arista.primary_l3_host_password,
verify=False,
timeout=cfg.CONF.l3_arista.conn_timeout
)
def _supports_vrf_instance(self, version):
ver_tokens = version.split('.')
if len(ver_tokens) < 2 or int(ver_tokens[0]) < 4:
return False
if int(ver_tokens[0]) == 4 and int(ver_tokens[1]) < 22:
return False
return True
def _check_vrf_syntax_v2_support(self, host, keep_alive=True):
cmds = ['show version']
result = None
try:
result = self._run_eos_cmds(cmds, host, log_exception=False,
keep_alive=keep_alive,
update_vrf_commands=False)
LOG.info(_LI('show version result %s'), result)
except Exception:
# We don't know what exception we got return None
# At this moment we don't know what command we support for vrf
# creation
return None
return result and self._supports_vrf_instance(result[0].get('version',
''))
def _update_vrf_commands(self, keep_alive=True):
# This assumes all switches run the same version. This needs to be
# updated if we'll support distributed routing
new_vrf_support = self._check_vrf_syntax_v2_support(
self._servers[0], keep_alive=keep_alive)
if new_vrf_support == self._vrf_syntax_v2_supported:
return
LOG.info(_LI('Updating VRF command supported: %s'),
'vrf instance' if new_vrf_support else 'vrf definition')
self._vrf_syntax_v2_supported = new_vrf_support
if self._vrf_syntax_v2_supported is False:
self._router_in_vrf = router_in_vrf_v1
else:
self._router_in_vrf = router_in_vrf_v2
# we don't need to update self.interfaceDict as it is updated by
# _select_dicts function before it is used
self.routerDict = self._router_in_vrf['router']
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.AristaServicePluginConfigError(msg=msg)
if cfg.CONF.l3_arista.get('mlag_config'):
if cfg.CONF.l3_arista.get('secondary_l3_host') == '':
msg = _('Required option secondary_l3_host is not set')
LOG.error(msg)
raise arista_exc.AristaServicePluginConfigError(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.AristaServicePluginConfigError(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_config_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_config_cmds(cmds, server)
def _select_dicts(self, ipv):
if self._use_vrf:
if ipv == 6:
msg = (_('IPv6 subnets are not supported with VRFs'))
LOG.info(msg)
self._interfaceDict = self._router_in_vrf['interface']
else:
if ipv == 6:
# for IPv6 use IPv6 commands
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, fixed_ip, 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 fixed_ip: Fixed IP associated with the port
: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:
# In VARP config, use router ID else, use fixed IP.
# If fixed Ip was not set this will be gateway IP address.
ip = router_ip
else:
ip = fixed_ip + '/' + mask
cmds.append(c.format(segment_id, router_name, ip))
if self._mlag_configured:
for c in self._additionalInterfaceCmdsDict['add']:
cmds.append(c.format(fixed_ip))
self._run_config_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_config_cmds(cmds, server)
def create_router(self, context, 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(router['id'],
router['name'])
hashed = hashlib.sha256(router_name.encode('utf-8'))
rdm = str(int(hashed.hexdigest(), 16) % 65536)
mlag_peer_failed = False
for s in self._servers:
try:
self.create_router_on_eos(router_name, rdm, s)
mlag_peer_failed = False
except Exception:
if self._mlag_configured and not mlag_peer_failed:
# In paired switch, it is OK to fail on one switch
mlag_peer_failed = True
else:
msg = (_('Failed to create router %s on EOS') %
router_name)
LOG.exception(msg)
raise arista_exc.AristaServicePluginRpcError(msg=msg)
if self._vrf_default_route:
ext_gateway = router.get('external_gateway_info')
if ext_gateway:
network_id = ext_gateway.get('network_id')
if network_id:
self._add_network_default_gateway(network_id)
def delete_router(self, context, router_id, router):
"""Deletes a router from Arista Switch."""
if router:
router_name = self._arista_router_name(router_id, router['name'])
mlag_peer_failed = False
for s in self._servers:
try:
self.delete_router_from_eos(router_name, s)
mlag_peer_failed = False
except Exception:
if self._mlag_configured and not mlag_peer_failed:
# In paired switch, it is OK to fail on one switch
mlag_peer_failed = True
else:
msg = (_('Failed to create router %s on EOS') %
router_name)
LOG.exception(msg)
raise arista_exc.AristaServicePluginRpcError(msg=msg)
def update_router(self, context, router_id, original_router, new_router):
"""Updates a router which is already created on Arista Switch.
"""
if not self._vrf_default_route or not new_router:
return
ext_gateway = new_router.get('external_gateway_info')
if ext_gateway is None:
# Remove default gateway if it exists
orig_ext_gateway = original_router.get('external_gateway_info')
if orig_ext_gateway is None:
# External gateway did not change
return
network_id = orig_ext_gateway['network_id']
ml2_db = NetworkContext(self, context, {'id': network_id})
seg_id = ml2_db.network_segments[0]['segmentation_id']
new_router['seg_id'] = seg_id
new_router['ip_version'] = 4
self.remove_router_interface(context, new_router,
delete_gateway=True)
return
network_id = ext_gateway.get('network_id')
if network_id:
self._reset_network_default_route(network_id)
def _setup_default_gateway(self, router_info):
mlag_peer_failed = False
gip = router_info['gip']
router_name = self._arista_router_name(router_info['id'],
router_info['name'])
for s in self._servers:
try:
self._setup_switch_default_gateway(router_name, gip, s)
mlag_peer_failed = False
except Exception:
if self._mlag_configured and not mlag_peer_failed:
# In paired switch, it is OK to fail on one switch
mlag_peer_failed = True
else:
msg = (_('Failed to setup router gateway %s on EOS') %
router_name)
LOG.exception(msg)
raise arista_exc.AristaServicePluginRpcError(msg=msg)
def _setup_switch_default_gateway(self, router_name, gip, server):
cmds = [
c.format(router_name, gip)
for c in additional_cmds_for_default_route['add']
]
self._run_config_cmds(cmds, server)
def _delete_default_gateway(self, router_name):
mlag_peer_failed = False
for s in self._servers:
try:
self._delete_switch_default_gateway(router_name, s)
mlag_peer_failed = False
except Exception:
if self._mlag_configured and not mlag_peer_failed:
# In paired switch, it is OK to fail on one switch
mlag_peer_failed = True
else:
msg = (_('Failed to delete router gateway %s on EOS') %
router_name)
LOG.exception(msg)
raise arista_exc.AristaServicePluginRpcError(msg=msg)
def _delete_switch_default_gateway(self, router_name, server):
cmds = [
c.format(router_name)
for c in additional_cmds_for_default_route['remove']
]
self._run_config_cmds(cmds, server)
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:
if router_info['ip_version'] == 6 and self._use_vrf:
# For the moment we ignore the interfaces to be added
# on IPv6 subnets.
LOG.info('Using VRFs. Ignoring IPv6 interface')
return
self._select_dicts(router_info['ip_version'])
cidr = router_info['cidr']
subnet_mask = cidr.split('/')[1]
router_name = self._arista_router_name(router_info['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
# unless either collides with the router interface fixed_ip,
# in which case we use x.x.x.252
mlag_peer_failed = False
router_ips = self._get_router_ips(cidr, len(self._servers),
router_info['ip_version'],
router_info['fixed_ip'])
for i, server in enumerate(self._servers):
# Get appropriate virtual IP address for this router
router_ip = router_ips[i]
try:
self.add_interface_to_router(router_info['seg_id'],
router_name,
router_info['fixed_ip'],
router_ip, subnet_mask,
server)
mlag_peer_failed = False
except Exception:
if not mlag_peer_failed:
mlag_peer_failed = True
else:
msg = (_('Failed to add interface to router '
'%s on EOS') % router_name)
LOG.exception(msg)
raise arista_exc.AristaServicePluginRpcError(
msg=msg)
else:
for s in self._servers:
self.add_interface_to_router(router_info['seg_id'],
router_name,
router_info['fixed_ip'],
None, subnet_mask, s)
def remove_router_interface(self, context, router_info,
delete_gateway=False):
"""Removes previously configured interface from router on Arista HW.
This deals with both IPv6 and IPv4 configurations.
"""
if router_info:
if router_info['ip_version'] == 6 and self._use_vrf:
# For the moment we ignore the interfaces to be added
# on IPv6 subnets.
LOG.info('Using VRFs. Ignoring IPv6 interface')
return
router_name = self._arista_router_name(router_info['id'],
router_info['name'])
mlag_peer_failed = False
for s in self._servers:
try:
if delete_gateway:
self._delete_switch_default_gateway(router_name, s)
self.delete_interface_from_router(router_info['seg_id'],
router_name, s)
if self._mlag_configured:
mlag_peer_failed = False
except Exception:
if self._mlag_configured and not mlag_peer_failed:
mlag_peer_failed = True
else:
msg = (_('Failed to remove interface to router '
'%s on EOS') % router_name)
LOG.exception(msg)
raise arista_exc.AristaServicePluginRpcError(msg=msg)
def _run_config_cmds(self, commands, server, log_exception=True,
keep_alive=True, update_vrf_commands=True):
"""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
self._run_eos_cmds(full_command, server, log_exception, keep_alive,
update_vrf_commands)
def _run_eos_cmds(self, commands, server, log_exception=True,
keep_alive=True, update_vrf_commands=True):
LOG.info(_LI('Executing command on Arista EOS: %s'), commands)
try:
# this returns array of return values for every command in
# full_command list
ret = server.execute(commands, keep_alive=keep_alive)
LOG.info(_LI('Results of execution on Arista EOS: %s'), ret)
return ret
except arista_exc.AristaServicePluginInvalidCommand:
msg = (_('VRF creation command unsupported. This request should '
'work on next retry.'))
if log_exception:
LOG.exception(msg)
if self._use_vrf and update_vrf_commands:
# For now we assume that the only command that raises this
# exception is vrf instance/definition and we need to update
# the current support
self._update_vrf_commands()
raise
except Exception:
msg = (_('Error occurred while trying to execute '
'commands %(cmd)s on EOS %(host)s') %
{'cmd': commands, 'host': server})
if log_exception:
LOG.exception(msg)
raise arista_exc.AristaServicePluginRpcError(msg=msg)
def _arista_router_name(self, router_id, name):
"""Generate an arista specific name for this router.
Use a unique name so that OpenStack created routers/SVIs
can be distinguishged from the user created routers/SVIs
on Arista HW. Replace spaces with underscores for CLI compatibility
"""
return '__OpenStack__' + router_id + '-' + name.replace(' ', '_')
def _get_binary_from_ipv4(self, ip_addr):
"""Converts IPv4 address to binary form."""
return struct.unpack("!L", socket.inet_pton(socket.AF_INET,
ip_addr))[0]
def _get_binary_from_ipv6(self, ip_addr):
"""Converts IPv6 address to binary form."""
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):
"""Converts binary address to Ipv4 format."""
return socket.inet_ntop(socket.AF_INET, struct.pack("!L", bin_addr))
def _get_ipv6_from_binary(self, bin_addr):
"""Converts binary address to Ipv6 format."""
hi = bin_addr >> 64
lo = bin_addr & 0xFFFFFFFF
return socket.inet_ntop(socket.AF_INET6, struct.pack("!QQ", hi, lo))
def _get_router_ips(self, cidr, ip_count, ip_ver, fixed_ip):
"""For a given IP subnet and IP version type, generate IP for router.
This method takes the network address (cidr) and selects a set of
IP addresses 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, skipping an address specified as the reserved_ip
"""
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_ips = list()
ip_idx = 0
while len(router_ips) < ip_count:
start_ip = MLAG_SWITCHES + ip_idx
router_ip = pow(2, bits - int(prefix)) - start_ip
router_ip = network_addr | router_ip
if ip_ver == 4:
router_ip = self._get_ipv4_from_binary(router_ip)
else:
router_ip = self._get_ipv6_from_binary(router_ip)
ip_idx += 1
if router_ip != fixed_ip.lower():
router_ips.append(router_ip + '/' + prefix)
return router_ips