From 4453818e3af2143e099a5f578c4a73b25abbfe58 Mon Sep 17 00:00:00 2001 From: =?utf8?q?Fran=C3=A7ois-R=C3=A9gis=20MENGUY?= Date: Tue, 27 Nov 2018 11:31:00 +0100 Subject: [PATCH] Add L3 traffic management with Neutron routers Change-Id: Ic9bff87e0d78652de28b3a756f9ebc342983cfbb Signed-off-by: fmenguy --- docs/development/design/traffic_desc.rst | 1 + docs/testing/user/userguide/advanced.rst | 2 +- .../user/userguide/images/nfvbench-pvpl3.png | Bin 0 -> 45570 bytes docs/testing/user/userguide/index.rst | 1 + docs/testing/user/userguide/pvpl3.rst | 66 ++++++ docs/testing/user/userguide/readme.rst | 2 + nfvbench/cfg.default.yaml | 40 ++++ nfvbench/chain_router.py | 186 ++++++++++++++++ nfvbench/chain_runner.py | 6 +- nfvbench/chaining.py | 135 ++++++++++-- nfvbench/cleanup.py | 237 ++++++++++++++++----- nfvbench/nfvbench.py | 5 + nfvbench/traffic_client.py | 21 +- requirements.txt | 1 + test/test_chains.py | 4 + test/test_nfvbench.py | 15 +- 16 files changed, 641 insertions(+), 81 deletions(-) create mode 100644 docs/testing/user/userguide/images/nfvbench-pvpl3.png create mode 100644 docs/testing/user/userguide/pvpl3.rst create mode 100644 nfvbench/chain_router.py diff --git a/docs/development/design/traffic_desc.rst b/docs/development/design/traffic_desc.rst index cd80a1c..bbd31a6 100644 --- a/docs/development/design/traffic_desc.rst +++ b/docs/development/design/traffic_desc.rst @@ -37,6 +37,7 @@ The destination MAC address is based on the configuration and can be: or when using a loopback cable - the dest MAC as specified by the configuration file (EXT chain no ARP) - the dest MAC as discovered by ARP (EXT chain) +- the router MAC as discovered from Neutron API (PVPL3 chain) - the VM MAC as dicovered from Neutron API (PVP, PVVP chains) NFVbench does not currently range on the MAC addresses. diff --git a/docs/testing/user/userguide/advanced.rst b/docs/testing/user/userguide/advanced.rst index 1a6e999..e49cfab 100644 --- a/docs/testing/user/userguide/advanced.rst +++ b/docs/testing/user/userguide/advanced.rst @@ -217,7 +217,7 @@ For example to run NFVbench with 3 PVP chains: It is not necessary to specify the service chain type (-sc) because PVP is set as default. The PVP service chains will have 3 VMs in 3 chains with this configuration. If ``-sc PVVP`` is specified instead, there would be 6 VMs in 3 chains as this service chain has 2 VMs per chain. -Both **single run** or **NDR/PDR** can be run as multichain. Runnin multichain is a scenario closer to a real life situation than runs with a single chain. +Both **single run** or **NDR/PDR** can be run as multichain. Running multichain is a scenario closer to a real life situation than runs with a single chain. Multiflow diff --git a/docs/testing/user/userguide/images/nfvbench-pvpl3.png b/docs/testing/user/userguide/images/nfvbench-pvpl3.png new file mode 100644 index 0000000000000000000000000000000000000000..d5837244545600c20aa87e5339feff2d5286c1f8 GIT binary patch literal 45570 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 literal 0 HcmV?d00001 diff --git a/docs/testing/user/userguide/index.rst b/docs/testing/user/userguide/index.rst index e83912f..84c79b0 100644 --- a/docs/testing/user/userguide/index.rst +++ b/docs/testing/user/userguide/index.rst @@ -24,6 +24,7 @@ Table of Content installation examples advanced + pvpl3 extchains fluentd sriov diff --git a/docs/testing/user/userguide/pvpl3.rst b/docs/testing/user/userguide/pvpl3.rst new file mode 100644 index 0000000..12f1d86 --- /dev/null +++ b/docs/testing/user/userguide/pvpl3.rst @@ -0,0 +1,66 @@ +.. This work is licensed under a Creative Commons Attribution 4.0 International License. +.. SPDX-License-Identifier: CC-BY-4.0 +.. (c) Cisco Systems, Inc + + +PVP L3 Router Internal Chain +-------------- + +NFVbench can measure the performance of 1 L3 service chain that are setup by NFVbench (VMs, routers and networks). + +PVP L3 router chain is made of 1 VNF (in vpp mode) and has exactly 2 end network interfaces (left and right internal network interfaces) that are connected to 2 neutron routers with 2 edge networks (left and right edge networks). +The PVP L3 router service chain can route L3 packets properly between the left and right networks. + +To run NFVbench on such PVP L3 router service chain: + +- explicitly tell NFVbench to use PVP service chain with L3 router option by adding ``-l3`` or ``--l3-router`` to NFVbench CLI options or ``l3_router: true`` in config +- explicitly tell NFVbench to use VPP forwarder with ``vm_forwarder: vpp`` in config +- specify the 2 end point networks (networks between NFVBench and neutron routers) of your environment in ``internal_networks`` inside the config file. + - The two networks specified will be created if not existing in Neutron and will be used as the end point networks by NFVbench ('lyon' and 'bordeaux' in the diagram below) +- specify the 2 edge networks (networks between neutron routers and loopback VM) of your environment in ``edge_networks`` inside the config file. + - The two networks specified will be created if not existing in Neutron and will be used as the router gateway networks by NFVbench ('paris' and 'marseille' in the diagram below) +- specify the router gateway IPs for the PVPL3 router service chain (1.2.0.1 and 2.2.0.1) +- specify the traffic generator gateway IPs for the PVPL3 router service chain (1.2.0.254 and 2.2.0.254 in diagram below) +- specify the packet source and destination IPs for the virtual devices that are simulated (10.0.0.0/8 and 20.0.0.0/8) + + +.. image:: images/nfvbench-pvpl3.png + +nfvbench configuration file: + +.. code-block:: bash + + vm_forwarder: vpp + + traffic_generator: + ip_addrs: ['10.0.0.0/8', '20.0.0.0/8'] + tg_gateway_ip_addrs: ['1.2.0.254', '2.2.0.254'] + gateway_ip_addrs: ['1.2.0.1', '2.2.0.1'] + + internal_networks: + left: + name: 'lyon' + cidr: '1.2.0.0/24' + gateway: '1.2.0.1' + right: + name: 'bordeaux' + cidr: '2.2.0.0/24' + gateway: '2.2.0.1' + + edge_networks: + left: + name: 'paris' + cidr: '1.1.0.0/24' + gateway: '1.1.0.1' + right: + name: 'marseille' + cidr: '2.1.0.0/24' + gateway: '2.1.0.1' + +Upon start, NFVbench will: +- first retrieve the properties of the left and right networks using Neutron APIs, +- extract the underlying network ID (typically VLAN segmentation ID), +- generate packets with the proper VLAN ID and measure traffic. + + +Please note: ``l3_router`` option is also compatible with external routers. In this case NFVBench will use ``EXT`` chain. \ No newline at end of file diff --git a/docs/testing/user/userguide/readme.rst b/docs/testing/user/userguide/readme.rst index acd4763..48c8b02 100644 --- a/docs/testing/user/userguide/readme.rst +++ b/docs/testing/user/userguide/readme.rst @@ -175,6 +175,8 @@ P2P (Physical interface to Physical interface - no VM) can be supported using th V2V (VM to VM) is not supported but PVVP provides a more complete (and more realistic) alternative. +PVP chain with L3 routers in the path can be supported using PVP chain with L3 forwarding mode (l3_router option). See PVP L3 Router Internal Chain section for more details. + Supported Neutron Network Plugins and vswitches ----------------------------------------------- diff --git a/nfvbench/cfg.default.yaml b/nfvbench/cfg.default.yaml index b2b9f49..0d6edd8 100755 --- a/nfvbench/cfg.default.yaml +++ b/nfvbench/cfg.default.yaml @@ -162,6 +162,7 @@ traffic_generator: # chain count consecutive IP addresses spaced by tg_gateway_ip_addrs_step will be used # `tg_gateway_ip_addrs__step`: step for generating traffic generator gateway sequences. default is 0.0.0.1 tg_gateway_ip_addrs: ['1.1.0.100', '2.2.0.100'] + tg_gateway_ip_cidrs: ['1.1.0.0/24','2.2.0.0/24'] tg_gateway_ip_addrs_step: 0.0.0.1 # `gateway_ip_addrs`: base IPs of VNF router gateways (left and right), quantity used depends on chain count # must correspond to the public IP on the left and right networks @@ -465,6 +466,40 @@ external_networks: left: right: +# PVP with L3 router in the packet path only. +# Only use when l3_router option is True (see l3_router) +# Prefix names of edge networks which will be used to send traffic via traffic generator. +# If a network with given name already exists it will be reused. +# Otherwise a new edge network will be created with that name, subnet and CIDR. +# +# gateway can be set in case of L3 traffic with edge networks - refer to edge_networks +# +# segmentation_id can be set to enforce a specific VLAN id - by default (empty) the VLAN id +# will be assigned by Neutron. +# Must be unique for each network +# physical_network can be set to pick a specific phsyical network - by default (empty) the +# default physical network will be picked +# +edge_networks: + left: + name: 'nfvbench-net2' + router_name: 'router_left' + subnet: 'nfvbench-subnet2' + cidr: '192.168.3.0/24' + gateway: + network_type: + segmentation_id: + physical_network: + right: + name: 'nfvbench-net3' + router_name: 'router_right' + subnet: 'nfvbench-subnet3' + cidr: '192.168.4.0/24' + gateway: + network_type: + segmentation_id: + physical_network: + # Use 'true' to enable VXLAN encapsulation support and sent by the traffic generator # When this option enabled internal networks 'network type' parameter value should be 'vxlan' vxlan: false @@ -525,6 +560,11 @@ traffic: # Can be overriden by --no-traffic no_traffic: false +# Use an L3 router in the packet path. This option if set will create or reuse an openstack neutron +# router (PVP, PVVP) or reuse an existing L3 router (EXT) to route traffic to the destination VM. +# Can be overriden by --l3-router +l3_router: false + # Test configuration # The rate pps for traffic going in reverse direction in case of unidirectional flow. Default to 1. diff --git a/nfvbench/chain_router.py b/nfvbench/chain_router.py new file mode 100644 index 0000000..9372716 --- /dev/null +++ b/nfvbench/chain_router.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python +# Copyright 2018 Cisco Systems, 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. +# + +# This module takes care of chaining routers +# +"""NFVBENCH CHAIN DISCOVERY/STAGING. + +This module takes care of staging/discovering resources that are participating in a +L3 benchmarking session: routers, networks, ports, routes. +If a resource is discovered with the same name, it will be reused. +Otherwise it will be created. + +Once created/discovered, instances are checked to be in the active state (ready to pass traffic) +Configuration parameters that will influence how these resources are staged/related: +- openstack or no openstack +- chain type +- number of chains +- number of VNF in each chain (PVP, PVVP) +- SRIOV and middle port SRIOV for port types +- whether networks are shared across chains or not + +There is not traffic generation involved in this module. +""" +import time + +from netaddr import IPAddress +from netaddr import IPNetwork + +from log import LOG + + +class ChainException(Exception): + """Exception while operating the chains.""" + + pass + + +class ChainRouter(object): + """Could be a shared router across all chains or a chain private router.""" + + def __init__(self, manager, name, subnets, routes): + """Create a router for given chain.""" + self.manager = manager + self.subnets = subnets + self.routes = routes + self.name = name + self.ports = [None, None] + self.reuse = False + self.router = None + try: + self._setup() + except Exception: + LOG.error("Error creating router %s", self.name) + self.delete() + raise + + def _setup(self): + # Lookup if there is a matching router with same name + routers = self.manager.neutron_client.list_routers(name=self.name) + + if routers['routers']: + router = routers['routers'][0] + # a router of same name already exists, we need to verify it has the same + # characteristics + if self.subnets: + for subnet in self.subnets: + if not self.get_router_interface(router['id'], subnet.network['subnets'][0]): + raise ChainException("Mismatch of 'subnet_id' for reused " + "router '{router}'.Router has no subnet id '{sub_id}'." + .format(router=self.name, + sub_id=subnet.network['subnets'][0])) + interfaces = self.manager.neutron_client.list_ports(device_id=router['id'])['ports'] + for interface in interfaces: + if self.is_ip_in_network( + interface['fixed_ips'][0]['ip_address'], + self.manager.config.traffic_generator.tg_gateway_ip_cidrs[0]) \ + or self.is_ip_in_network( + interface['fixed_ips'][0]['ip_address'], + self.manager.config.traffic_generator.tg_gateway_ip_cidrs[1]): + self.ports[0] = interface + else: + self.ports[1] = interface + if self.routes: + for route in self.routes: + if route not in router['routes']: + LOG.info("Mismatch of 'router' for reused router '%s'." + "Router has no existing route destination '%s', " + "and nexthop '%s'.", self.name, + route['destination'], + route['nexthop']) + LOG.info("New route added to router %s for reused ", self.name) + body = { + 'router': { + 'routes': self.routes + } + } + self.manager.neutron_client.update_router(router['id'], body) + + LOG.info('Reusing existing router: %s', self.name) + self.reuse = True + self.router = router + return + + body = { + 'router': { + 'name': self.name, + 'admin_state_up': True + } + } + router = self.manager.neutron_client.create_router(body)['router'] + router_id = router['id'] + + if self.subnets: + for subnet in self.subnets: + router_interface = {'subnet_id': subnet.network['subnets'][0]} + self.manager.neutron_client.add_interface_router(router_id, router_interface) + interfaces = self.manager.neutron_client.list_ports(device_id=router_id)['ports'] + for interface in interfaces: + itf = interface['fixed_ips'][0]['ip_address'] + cidr0 = self.manager.config.traffic_generator.tg_gateway_ip_cidrs[0] + cidr1 = self.manager.config.traffic_generator.tg_gateway_ip_cidrs[1] + if self.is_ip_in_network(itf, cidr0) or self.is_ip_in_network(itf, cidr1): + self.ports[0] = interface + else: + self.ports[1] = interface + + if self.routes: + body = { + 'router': { + 'routes': self.routes + } + } + self.manager.neutron_client.update_router(router_id, body) + + LOG.info('Created router: %s.', self.name) + self.router = self.manager.neutron_client.show_router(router_id) + + def get_uuid(self): + """ + Extract UUID of this router. + + :return: UUID of this router + """ + return self.router['id'] + + def get_router_interface(self, router_id, subnet_id): + interfaces = self.manager.neutron_client.list_ports(device_id=router_id)['ports'] + matching_interface = None + for interface in interfaces: + if interface['fixed_ips'][0]['subnet_id'] == subnet_id: + matching_interface = interface + return matching_interface + + def is_ip_in_network(self, interface_ip, cidr): + return IPAddress(interface_ip) in IPNetwork(cidr) + + def delete(self): + """Delete this router.""" + if not self.reuse and self.router: + retry = 0 + while retry < self.manager.config.generic_retry_count: + try: + self.manager.neutron_client.delete_router(self.router['id']) + LOG.info("Deleted router: %s", self.name) + return + except Exception: + retry += 1 + LOG.info('Error deleting router %s (retry %d/%d)...', + self.name, + retry, + self.manager.config.generic_retry_count) + time.sleep(self.manager.config.generic_poll_sec) + LOG.error('Unable to delete router: %s', self.name) diff --git a/nfvbench/chain_runner.py b/nfvbench/chain_runner.py index 627e9ea..833373c 100644 --- a/nfvbench/chain_runner.py +++ b/nfvbench/chain_runner.py @@ -78,7 +78,7 @@ class ChainRunner(object): # Note that in the case of EXT+ARP+VxLAN, the dest MACs need to be loaded # because ARP only operates on the dest VTEP IP not on the VM dest MAC if not config.l2_loopback and \ - (config.service_chain != ChainType.EXT or config.no_arp or config.vxlan): + (config.service_chain != ChainType.EXT or config.no_arp or config.vxlan): gen_config.set_dest_macs(0, self.chain_manager.get_dest_macs(0)) gen_config.set_dest_macs(1, self.chain_manager.get_dest_macs(1)) @@ -104,8 +104,8 @@ class ChainRunner(object): self.traffic_client.setup() if not self.config.no_traffic: # ARP is needed for EXT chain or VxLAN overlay unless disabled explicitly - if (self.config.service_chain == ChainType.EXT or self.config.vxlan) and \ - not self.config.no_arp: + if (self.config.service_chain == ChainType.EXT or + self.config.vxlan or self.config.l3_router) and not self.config.no_arp: self.traffic_client.ensure_arp_successful() self.traffic_client.ensure_end_to_end() diff --git a/nfvbench/chaining.py b/nfvbench/chaining.py index 898e9ea..3350299 100644 --- a/nfvbench/chaining.py +++ b/nfvbench/chaining.py @@ -54,13 +54,16 @@ from neutronclient.neutron import client as neutronclient from novaclient.client import Client from attrdict import AttrDict +from chain_router import ChainRouter import compute from log import LOG from specs import ChainType - # Left and right index for network and port lists LEFT = 0 RIGHT = 1 +# L3 traffic edge networks are at the end of networks list +EDGE_LEFT = -2 +EDGE_RIGHT = -1 # Name of the VM config file NFVBENCH_CFG_FILENAME = 'nfvbenchvm.conf' # full pathame of the VM config in the VM @@ -76,6 +79,7 @@ class ChainException(Exception): pass + class NetworkEncaps(object): """Network encapsulation.""" @@ -174,6 +178,10 @@ class ChainVnfPort(object): """Get the MAC address for this port.""" return self.port['mac_address'] + def get_ip(self): + """Get the IP address for this port.""" + return self.port['fixed_ips'][0]['ip_address'] + def delete(self): """Delete this port instance.""" if self.reuse or not self.port: @@ -222,6 +230,8 @@ class ChainNetwork(object): self.reuse = False self.network = None self.vlan = None + if manager.config.l3_router and hasattr(network_config, 'router_name'): + self.router_name = network_config.router_name try: self._setup(network_config, lookup_only) except Exception: @@ -294,7 +304,7 @@ class ChainNetwork(object): 'network': { 'name': self.name, 'admin_state_up': True - } + } } if network_config.network_type: body['network']['provider:network_type'] = network_config.network_type @@ -388,6 +398,7 @@ class ChainVnf(object): # For example if 7 idle interfaces are requested, the corresp. ports will be # at index 2 to 8 self.ports = [] + self.routers = [] self.status = None self.instance = None self.reuse = False @@ -397,7 +408,10 @@ class ChainVnf(object): try: # the vnf_id is conveniently also the starting index in networks # for the left and right networks associated to this VNF - self._setup(networks[vnf_id:vnf_id + 2]) + if self.manager.config.l3_router: + self._setup(networks[vnf_id:vnf_id + 4]) + else: + self._setup(networks[vnf_id:vnf_id + 2]) except Exception: LOG.error("Error creating VNF %s", self.name) self.delete() @@ -406,22 +420,52 @@ class ChainVnf(object): def _get_vm_config(self, remote_mac_pair): config = self.manager.config devices = self.manager.generator_config.devices + + if config.l3_router: + tg_gateway1_ip = self.routers[LEFT].ports[1]['fixed_ips'][0][ + 'ip_address'] # router edge ip left + tg_gateway2_ip = self.routers[RIGHT].ports[1]['fixed_ips'][0][ + 'ip_address'] # router edge ip right + tg_mac1 = self.routers[LEFT].ports[1]['mac_address'] # router edge mac left + tg_mac2 = self.routers[RIGHT].ports[1]['mac_address'] # router edge mac right + # edge cidr mask left + vnf_gateway1_cidr = \ + self.ports[LEFT].get_ip() + self.manager.config.edge_networks.left.cidr[-3:] + # edge cidr mask right + vnf_gateway2_cidr = \ + self.ports[RIGHT].get_ip() + self.manager.config.edge_networks.right.cidr[-3:] + if config.vm_forwarder != 'vpp': + raise ChainException( + 'L3 router mode imply to set VPP as VM forwarder.' + 'Please update your config file with: vm_forwarder: vpp') + else: + tg_gateway1_ip = devices[LEFT].tg_gateway_ip_addrs + tg_gateway2_ip = devices[RIGHT].tg_gateway_ip_addrs + tg_mac1 = remote_mac_pair[0] + tg_mac2 = remote_mac_pair[1] + + g1cidr = devices[LEFT].get_gw_ip( + self.chain.chain_id) + self.manager.config.internal_networks.left.cidr[-3:] + g2cidr = devices[RIGHT].get_gw_ip( + self.chain.chain_id) + self.manager.config.internal_networks.right.cidr[-3:] + + vnf_gateway1_cidr = g1cidr + vnf_gateway2_cidr = g2cidr + with open(BOOT_SCRIPT_PATHNAME, 'r') as boot_script: content = boot_script.read() - g1cidr = devices[LEFT].get_gw_ip(self.chain.chain_id) + '/8' - g2cidr = devices[RIGHT].get_gw_ip(self.chain.chain_id) + '/8' vm_config = { 'forwarder': config.vm_forwarder, 'intf_mac1': self.ports[LEFT].get_mac(), 'intf_mac2': self.ports[RIGHT].get_mac(), - 'tg_gateway1_ip': devices[LEFT].tg_gateway_ip_addrs, - 'tg_gateway2_ip': devices[RIGHT].tg_gateway_ip_addrs, + 'tg_gateway1_ip': tg_gateway1_ip, + 'tg_gateway2_ip': tg_gateway2_ip, 'tg_net1': devices[LEFT].ip_addrs, 'tg_net2': devices[RIGHT].ip_addrs, - 'vnf_gateway1_cidr': g1cidr, - 'vnf_gateway2_cidr': g2cidr, - 'tg_mac1': remote_mac_pair[0], - 'tg_mac2': remote_mac_pair[1], + 'vnf_gateway1_cidr': vnf_gateway1_cidr, + 'vnf_gateway2_cidr': vnf_gateway2_cidr, + 'tg_mac1': tg_mac1, + 'tg_mac2': tg_mac2, 'vif_mq_size': config.vif_multiqueue_size } return content.format(**vm_config) @@ -505,21 +549,27 @@ class ChainVnf(object): # Check if we can reuse an instance with same name for instance in self.manager.existing_instances: if instance.name == self.name: + instance_left = LEFT + instance_right = RIGHT + # In case of L3 traffic instance use edge networks + if self.manager.config.l3_router: + instance_left = EDGE_LEFT + instance_right = EDGE_RIGHT # Verify that other instance characteristics match if instance.flavor['id'] != flavor_id: self._reuse_exception('Flavor mismatch') if instance.status != "ACTIVE": self._reuse_exception('Matching instance is not in ACTIVE state') # The 2 networks for this instance must also be reused - if not networks[LEFT].reuse: - self._reuse_exception('network %s is new' % networks[LEFT].name) - if not networks[RIGHT].reuse: - self._reuse_exception('network %s is new' % networks[RIGHT].name) + if not networks[instance_left].reuse: + self._reuse_exception('network %s is new' % networks[instance_left].name) + if not networks[instance_right].reuse: + self._reuse_exception('network %s is new' % networks[instance_right].name) # instance.networks have the network names as keys: # {'nfvbench-rnet0': ['192.168.2.10'], 'nfvbench-lnet0': ['192.168.1.8']} - if networks[LEFT].name not in instance.networks: + if networks[instance_left].name not in instance.networks: self._reuse_exception('Left network mismatch') - if networks[RIGHT].name not in instance.networks: + if networks[instance_right].name not in instance.networks: self._reuse_exception('Right network mismatch') self.reuse = True @@ -527,16 +577,51 @@ class ChainVnf(object): LOG.info('Reusing existing instance %s on %s', self.name, self.get_hypervisor_name()) # create or reuse/discover 2 ports per instance - self.ports = [ChainVnfPort(self.name + '-' + str(index), - self, - networks[index], - self._get_vnic_type(index)) for index in [0, 1]] + if self.manager.config.l3_router: + self.ports = [ChainVnfPort(self.name + '-' + str(index), + self, + networks[index + 2], + self._get_vnic_type(index)) for index in [0, 1]] + else: + self.ports = [ChainVnfPort(self.name + '-' + str(index), + self, + networks[index], + self._get_vnic_type(index)) for index in [0, 1]] # create idle networks and ports only if instance is not reused # if reused, we do not care about idle networks/ports if not self.reuse: self._get_idle_networks_ports() + # Create neutron routers for L3 traffic use case + if self.manager.config.l3_router and self.manager.openstack: + internal_nets = networks[:2] + if self.manager.config.service_chain == ChainType.PVP: + edge_nets = networks[2:] + else: + edge_nets = networks[3:] + subnets_left = [internal_nets[0], edge_nets[0]] + routes_left = [{'destination': self.manager.config.traffic_generator.ip_addrs[0], + 'nexthop': self.manager.config.traffic_generator.tg_gateway_ip_addrs[ + 0]}, + {'destination': self.manager.config.traffic_generator.ip_addrs[1], + 'nexthop': self.ports[0].get_ip()}] + self.routers.append( + ChainRouter(self.manager, edge_nets[0].router_name, subnets_left, routes_left)) + subnets_right = [internal_nets[1], edge_nets[1]] + routes_right = [{'destination': self.manager.config.traffic_generator.ip_addrs[0], + 'nexthop': self.ports[1].get_ip()}, + {'destination': self.manager.config.traffic_generator.ip_addrs[1], + 'nexthop': self.manager.config.traffic_generator.tg_gateway_ip_addrs[ + 1]}] + self.routers.append( + ChainRouter(self.manager, edge_nets[1].router_name, subnets_right, routes_right)) + # Overload gateway_ips property with router ip address for ARP and traffic calls + self.manager.generator_config.devices[LEFT].set_gw_ip( + self.routers[LEFT].ports[0]['fixed_ips'][0]['ip_address']) # router edge ip left) + self.manager.generator_config.devices[RIGHT].set_gw_ip( + self.routers[RIGHT].ports[0]['fixed_ips'][0]['ip_address']) # router edge ip right) + # if no reuse, actual vm creation is deferred after all ports in the chain are created # since we need to know the next mac in a multi-vnf chain @@ -659,6 +744,7 @@ class ChainVnf(object): for network in self.idle_networks: network.delete() + class Chain(object): """A class to manage a single chain. @@ -720,7 +806,8 @@ class Chain(object): def get_length(self): """Get the number of VNF in the chain.""" - return len(self.networks) - 1 + # Take into account 2 edge networks for routers + return len(self.networks) - 3 if self.manager.config.l3_router else len(self.networks) - 1 def _get_remote_mac_pairs(self): """Get the list of remote mac pairs for every VNF in the chain. @@ -1156,6 +1243,10 @@ class ChainManager(object): net_cfg = [int_nets.left, int_nets.right] else: net_cfg = [int_nets.left, int_nets.middle, int_nets.right] + if self.config.l3_router: + edge_nets = self.config.edge_networks + net_cfg.append(edge_nets.left) + net_cfg.append(edge_nets.right) networks = [] try: for cfg in net_cfg: diff --git a/nfvbench/cleanup.py b/nfvbench/cleanup.py index 6b13f69..fc85b5d 100644 --- a/nfvbench/cleanup.py +++ b/nfvbench/cleanup.py @@ -25,6 +25,7 @@ from tabulate import tabulate import credentials as credentials from log import LOG + class ComputeCleaner(object): """A cleaner for compute resources.""" @@ -45,30 +46,42 @@ class ComputeCleaner(object): def get_resource_list(self): return [["Instance", server.name, server.id] for server in self.servers] - def clean(self): - if self.servers: - for server in self.servers: - try: - LOG.info('Deleting instance %s...', server.name) - self.nova_client.servers.delete(server.id) - except Exception: - LOG.exception("Instance %s deletion failed", server.name) - LOG.info(' Waiting for %d instances to be fully deleted...', len(self.servers)) - retry_count = 15 + len(self.servers) * 5 - while True: - retry_count -= 1 - self.servers = [server for server in self.servers if self.instance_exists(server)] - if not self.servers: - break + def get_cleaner_code(self): + return "instances" - if retry_count: - LOG.info(' %d yet to be deleted by Nova, retries left=%d...', - len(self.servers), retry_count) - time.sleep(2) - else: - LOG.warning(' instance deletion verification time-out: %d still not deleted', - len(self.servers)) - break + def clean_needed(self, clean_options): + if clean_options is None: + return True + code = self.get_cleaner_code() + return code[0] in clean_options + + def clean(self, clean_options): + if self.clean_needed(clean_options): + if self.servers: + for server in self.servers: + try: + LOG.info('Deleting instance %s...', server.name) + self.nova_client.servers.delete(server.id) + except Exception: + LOG.exception("Instance %s deletion failed", server.name) + LOG.info(' Waiting for %d instances to be fully deleted...', len(self.servers)) + retry_count = 15 + len(self.servers) * 5 + while True: + retry_count -= 1 + self.servers = [server for server in self.servers if + self.instance_exists(server)] + if not self.servers: + break + + if retry_count: + LOG.info(' %d yet to be deleted by Nova, retries left=%d...', + len(self.servers), retry_count) + time.sleep(2) + else: + LOG.warning( + ' instance deletion verification time-out: %d still not deleted', + len(self.servers)) + break class NetworkCleaner(object): @@ -99,21 +112,103 @@ class NetworkCleaner(object): res_list.extend([["Port", port['name'], port['id']] for port in self.ports]) return res_list - def clean(self): - for port in self.ports: - LOG.info("Deleting port %s...", port['id']) - try: - self.neutron_client.delete_port(port['id']) - except Exception: - LOG.exception("Port deletion failed") - - # associated subnets are automatically deleted by neutron - for net in self.networks: - LOG.info("Deleting network %s...", net['name']) - try: - self.neutron_client.delete_network(net['id']) - except Exception: - LOG.exception("Network deletion failed") + def get_cleaner_code(self): + return "networks and ports" + + def clean_needed(self, clean_options): + if clean_options is None: + return True + code = self.get_cleaner_code() + return code[0] in clean_options + + def clean(self, clean_options): + if self.clean_needed(clean_options): + for port in self.ports: + LOG.info("Deleting port %s...", port['id']) + try: + self.neutron_client.delete_port(port['id']) + except Exception: + LOG.exception("Port deletion failed") + + # associated subnets are automatically deleted by neutron + for net in self.networks: + LOG.info("Deleting network %s...", net['name']) + try: + self.neutron_client.delete_network(net['id']) + except Exception: + LOG.exception("Network deletion failed") + + +class RouterCleaner(object): + """A cleaner for router resources.""" + + def __init__(self, neutron_client, router_names): + self.neutron_client = neutron_client + LOG.info('Discovering routers...') + all_routers = self.neutron_client.list_routers()['routers'] + self.routers = [] + self.ports = [] + self.routes = [] + rtr_ids = [] + for rtr in all_routers: + rtrname = rtr['name'] + for name in router_names: + if rtrname == name: + self.routers.append(rtr) + rtr_ids.append(rtr['id']) + + LOG.info('Discovering router routes for router %s...', rtr['name']) + all_routes = rtr['routes'] + for route in all_routes: + LOG.info("destination: %s, nexthop: %s", route['destination'], + route['nexthop']) + + LOG.info('Discovering router ports for router %s...', rtr['name']) + self.ports.extend(self.neutron_client.list_ports(device_id=rtr['id'])['ports']) + break + + def get_resource_list(self): + res_list = [["Router", rtr['name'], rtr['id']] for rtr in self.routers] + return res_list + + def get_cleaner_code(self): + return "router" + + def clean_needed(self, clean_options): + if clean_options is None: + return True + code = self.get_cleaner_code() + return code[0] in clean_options + + def clean(self, clean_options): + if self.clean_needed(clean_options): + # associated routes needs to be deleted before deleting routers + for rtr in self.routers: + LOG.info("Deleting routes for %s...", rtr['name']) + try: + body = { + 'router': { + 'routes': [] + } + } + self.neutron_client.update_router(rtr['id'], body) + except Exception: + LOG.exception("Router routes deletion failed") + LOG.info("Deleting ports for %s...", rtr['name']) + try: + for port in self.ports: + body = { + 'port_id': port['id'] + } + self.neutron_client.remove_interface_router(rtr['id'], body) + except Exception: + LOG.exception("Router ports deletion failed") + LOG.info("Deleting router %s...", rtr['name']) + try: + self.neutron_client.delete_router(rtr['id']) + except Exception: + LOG.exception("Router deletion failed") + class FlavorCleaner(object): """Cleaner for NFVbench flavor.""" @@ -131,13 +226,24 @@ class FlavorCleaner(object): return [['Flavor', self.name, self.flavor.id]] return None - def clean(self): - if self.flavor: - LOG.info("Deleting flavor %s...", self.flavor.name) - try: - self.flavor.delete() - except Exception: - LOG.exception("Flavor deletion failed") + def get_cleaner_code(self): + return "flavor" + + def clean_needed(self, clean_options): + if clean_options is None: + return True + code = self.get_cleaner_code() + return code[0] in clean_options + + def clean(self, clean_options): + if self.clean_needed(clean_options): + if self.flavor: + LOG.info("Deleting flavor %s...", self.flavor.name) + try: + self.flavor.delete() + except Exception: + LOG.exception("Flavor deletion failed") + class Cleaner(object): """Cleaner for all NFVbench resources.""" @@ -148,12 +254,15 @@ class Cleaner(object): self.neutron_client = nclient.Client('2.0', session=session) self.nova_client = Client(2, session=session) network_names = [inet['name'] for inet in config.internal_networks.values()] + network_names.extend([inet['name'] for inet in config.edge_networks.values()]) + router_names = [rtr['router_name'] for rtr in config.edge_networks.values()] # add idle networks as well if config.idle_networks.name: network_names.append(config.idle_networks.name) self.cleaners = [ComputeCleaner(self.nova_client, config.loop_vm_name), FlavorCleaner(self.nova_client, config.flavor_type), - NetworkCleaner(self.neutron_client, network_names)] + NetworkCleaner(self.neutron_client, network_names), + RouterCleaner(self.neutron_client, router_names)] def show_resources(self): """Show all NFVbench resources.""" @@ -172,11 +281,37 @@ class Cleaner(object): def clean(self, prompt): """Clean all resources.""" - LOG.info("NFVbench will delete all resources shown...") + LOG.info("NFVbench will delete resources shown...") + clean_options = None if prompt: - answer = raw_input("Are you sure? (y/n) ") + answer = raw_input("Do you want to delete all ressources? (y/n) ") if answer.lower() != 'y': - LOG.info("Exiting without deleting any resource") - sys.exit(0) + print "What kind of resources do you want to delete?" + all_option = "" + all_option_codes = [] + for cleaner in self.cleaners: + code = cleaner.get_cleaner_code() + print "%s: %s" % (code[0], code) + all_option += code[0] + all_option_codes.append(code) + print "a: all resources - a shortcut for '%s'" % all_option + all_option_codes.append("all resources") + print "q: quit" + answer_res = raw_input(":").lower() + # Check only first character because answer_res can be "flavor" and it is != all + if answer_res[0] == "a": + clean_options = all_option + elif answer_res[0] != 'q': + # if user write complete code instead of shortcuts + # Get only first character of clean code to avoid false clean request + # i.e "networks and ports" and "router" have 1 letter in common and router clean + # will be called even if user ask for networks and ports + if answer_res in all_option_codes: + clean_options = answer_res[0] + else: + clean_options = answer_res + else: + LOG.info("Exiting without deleting any resource") + sys.exit(0) for cleaner in self.cleaners: - cleaner.clean() + cleaner.clean(clean_options) diff --git a/nfvbench/nfvbench.py b/nfvbench/nfvbench.py index b2163ba..4a2a285 100644 --- a/nfvbench/nfvbench.py +++ b/nfvbench/nfvbench.py @@ -326,6 +326,11 @@ def _parse_opts_from_cli(): action='store', help='Traffic generator profile to use') + parser.add_argument('-l3', '--l3-router', dest='l3_router', + default=None, + action='store_true', + help='Use L3 neutron routers to handle traffic') + parser.add_argument('-0', '--no-traffic', dest='no_traffic', default=None, action='store_true', diff --git a/nfvbench/traffic_client.py b/nfvbench/traffic_client.py index 75c40c1..d69da0e 100755 --- a/nfvbench/traffic_client.py +++ b/nfvbench/traffic_client.py @@ -23,7 +23,9 @@ from attrdict import AttrDict import bitmath from netaddr import IPNetwork # pylint: disable=import-error +from trex.stl.api import Ether from trex.stl.api import STLError +from trex.stl.api import UDP # pylint: enable=import-error from log import LOG @@ -241,6 +243,11 @@ class Device(object): self.vnis = vnis LOG.info("Port %d: VNIs %s", self.port, self.vnis) + def set_gw_ip(self, gateway_ip): + self.gw_ip_block = IpBlock(gateway_ip, + self.generator_config.gateway_ip_addrs_step, + self.chain_count) + def get_gw_ip(self, chain_index): """Retrieve the IP address assigned for the gateway of a given chain.""" return self.gw_ip_block.get_ip(chain_index) @@ -611,11 +618,10 @@ class TrafficClient(object): self.gen.stop_traffic() self.gen.fetch_capture_packets() self.gen.stop_capture() - for packet in self.gen.packet_list: mac_id = get_mac_id(packet) src_mac = ':'.join(["%02x" % ord(x) for x in mac_id]) - if src_mac in mac_map: + if src_mac in mac_map and self.is_udp(packet): port, chain = mac_map[src_mac] LOG.info('Received packet from mac: %s (chain=%d, port=%d)', src_mac, chain, port) @@ -624,9 +630,18 @@ class TrafficClient(object): if not mac_map: LOG.info('End-to-end connectivity established') return - + if self.config.l3_router and not self.config.no_arp: + # In case of L3 traffic mode, routers are not able to route traffic + # until VM interfaces are up and ARP requests are done + LOG.info('Waiting for loopback service completely started...') + LOG.info('Sending ARP request to assure end-to-end connectivity established') + self.ensure_arp_successful() raise TrafficClientException('End-to-end connectivity cannot be ensured') + def is_udp(self, packet): + pkt = Ether(packet['binary']) + return UDP in pkt + def ensure_arp_successful(self): """Resolve all IP using ARP and throw an exception in case of failure.""" dest_macs = self.gen.resolve_arp() diff --git a/requirements.txt b/requirements.txt index 490864c..9eb76c4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,3 +21,4 @@ requests>=2.13.0 tabulate>=0.7.5 flask>=0.12 fluent-logger>=0.5.3 +netaddr>=0.7.19 diff --git a/test/test_chains.py b/test/test_chains.py index 5490dfc..5fd1ce6 100644 --- a/test/test_chains.py +++ b/test/test_chains.py @@ -271,6 +271,7 @@ def _mock_get_mac(dummy): @patch.object(Compute, 'find_image', _mock_find_image) @patch.object(TrafficClient, 'skip_sleep', lambda x: True) @patch.object(ChainVnfPort, 'get_mac', _mock_get_mac) +@patch.object(TrafficClient, 'is_udp', lambda x, y: True) @patch('nfvbench.chaining.Client') @patch('nfvbench.chaining.neutronclient') @patch('nfvbench.chaining.glanceclient') @@ -287,6 +288,7 @@ def test_nfvbench_run(mock_cred, mock_glance, mock_neutron, mock_client): @patch.object(Compute, 'find_image', _mock_find_image) @patch.object(TrafficClient, 'skip_sleep', lambda x: True) +@patch.object(TrafficClient, 'is_udp', lambda x, y: True) @patch('nfvbench.chaining.Client') @patch('nfvbench.chaining.neutronclient') @patch('nfvbench.chaining.glanceclient') @@ -302,6 +304,7 @@ def test_nfvbench_ext_arp(mock_cred, mock_glance, mock_neutron, mock_client): @patch.object(Compute, 'find_image', _mock_find_image) @patch.object(TrafficClient, 'skip_sleep', lambda x: True) +@patch.object(TrafficClient, 'is_udp', lambda x, y: True) @patch('nfvbench.chaining.Client') @patch('nfvbench.chaining.neutronclient') @patch('nfvbench.chaining.glanceclient') @@ -466,6 +469,7 @@ def test_summarizer(): assert stats == exp_stats @patch.object(TrafficClient, 'skip_sleep', lambda x: True) +@patch.object(TrafficClient, 'is_udp', lambda x, y: True) def test_fixed_rate_no_openstack(): """Test FIxed Rate run - no openstack.""" config = _get_chain_config(ChainType.EXT, 1, True, rate='100%') diff --git a/test/test_nfvbench.py b/test/test_nfvbench.py index 2a7ca77..7c5fb83 100644 --- a/test/test_nfvbench.py +++ b/test/test_nfvbench.py @@ -152,6 +152,19 @@ def test_ip_block(): assert ipb.get_ip(255) == '10.0.0.255' with pytest.raises(IndexError): ipb.get_ip(256) + ipb = IpBlock('10.0.0.0', '0.0.0.1', 1) + assert ipb.get_ip() == '10.0.0.0' + with pytest.raises(IndexError): + ipb.get_ip(1) + + ipb = IpBlock('10.0.0.0', '0.0.0.2', 256) + assert ipb.get_ip() == '10.0.0.0' + assert ipb.get_ip(1) == '10.0.0.2' + assert ipb.get_ip(127) == '10.0.0.254' + assert ipb.get_ip(128) == '10.0.1.0' + with pytest.raises(IndexError): + ipb.get_ip(256) + # verify with step larger than 1 ipb = IpBlock('10.0.0.0', '0.0.0.2', 256) assert ipb.get_ip() == '10.0.0.0' @@ -341,7 +354,7 @@ def test_ndr_at_lr(): # tx packets should be line rate for 64B and no drops... assert tg.get_tx_pps_dropped_pps(100) == (LR_64B_PPS, 0) # NDR and PDR should be at 100% - traffic_client.ensure_end_to_end() + # traffic_client.ensure_end_to_end() results = traffic_client.get_ndr_and_pdr() assert_ndr_pdr(results, 200.0, 0.0, 200.0, 0.0) -- 2.16.6