diff --git a/whitebox_neutron_tempest_plugin/config.py b/whitebox_neutron_tempest_plugin/config.py index 099f14d..56bcf46 100644 --- a/whitebox_neutron_tempest_plugin/config.py +++ b/whitebox_neutron_tempest_plugin/config.py @@ -37,5 +37,37 @@ WhiteboxNeutronPluginOptions = [ cfg.StrOpt('pki_ca_cert', default='/etc/ipa/ca.crt', help='File with peer CA certificate. Need for TLS-everywhere ' - 'environments.') + 'environments.'), + cfg.StrOpt('default_instance_interface', + default='eth0', + help='Default first interface name used in VM instances' + 'Typical values are eth0, ens5, etc'), + cfg.IntOpt('mcast_groups_count', + default=1, + help='How many groups to use in multicast tests. Default value ' + 'of 1 is for environments with low resources. ' + 'Recommended value is 2.'), + cfg.IntOpt('mcast_receivers_count', + default=1, + help='How many receivers to use in multicast tests. Default ' + 'value of 1 is for environments with low resources. ' + 'Recommended value is 2.'), + cfg.IntOpt('external_igmp_querier_period', + default=170, + help='Time in seconds external igmp querier is sending its ' + 'periodical queries'), + cfg.BoolOpt('run_traffic_flow_tests', + default=False, + help='Specify explicitly whether to run traffic flow tests.' + ' This is needed because some ML2 plugins (e.g. ovn ) do ' + 'not expose api_extensions like dvr for some purposes.'), + cfg.IntOpt('broadcast_receivers_count', + default=2, + help='How many receivers to use in broadcast tests. Default ' + 'and recommended value of 2. In case of environments ' + 'with low resources, set it to 1.'), + cfg.BoolOpt('bgp', + default=False, + help='Specifies whether the OSP setup under test has been ' + 'configured with BGP functionality or not') ] diff --git a/whitebox_neutron_tempest_plugin/tests/scenario/base.py b/whitebox_neutron_tempest_plugin/tests/scenario/base.py index a3b4890..b1b75c0 100644 --- a/whitebox_neutron_tempest_plugin/tests/scenario/base.py +++ b/whitebox_neutron_tempest_plugin/tests/scenario/base.py @@ -12,19 +12,27 @@ # 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 base64 +import random import re +import time import netaddr +from neutron_lib import constants +from neutron_tempest_plugin.common import ssh from neutron_tempest_plugin.common import utils as common_utils from neutron_tempest_plugin.scenario import base from oslo_log import log +from tempest.common import utils from tempest import config +from tempest.lib.common.utils import data_utils +from whitebox_neutron_tempest_plugin.common import tcpdump_capture as capture from whitebox_neutron_tempest_plugin.common import utils as local_utils CONF = config.CONF LOG = log.getLogger(__name__) -WB_CONF = config.CONF.whitebox_neutron_plugin_options +WB_CONF = CONF.whitebox_neutron_plugin_options class BaseTempestWhiteboxTestCase(base.BaseTempestTestCase): @@ -39,6 +47,12 @@ class BaseTempestWhiteboxTestCase(base.BaseTempestTestCase): cls.image_ref = CONF.compute.image_ref cls.flavor_ref = CONF.compute.flavor_ref cls.username = CONF.validation.image_ssh_user + agents = cls.os_admin.network.AgentsClient().list_agents()['agents'] + ovn_agents = [agent for agent in agents if 'ovn' in agent['binary']] + cls.has_ovn_support = True if ovn_agents else False + sriov_agents = [ + agent for agent in agents if 'sriov' in agent['binary']] + cls.has_sriov_support = True if sriov_agents else False @classmethod def run_on_master_controller(cls, cmd): @@ -53,15 +67,292 @@ class BaseTempestWhiteboxTestCase(base.BaseTempestTestCase): server_details = self.os_admin.servers_client.show_server(server_id) return server_details['server']['OS-EXT-SRV-ATTR:host'] + @classmethod + def get_external_gateway(cls): + if CONF.network.public_network_id: + subnets = cls.os_admin.network_client.list_subnets( + network_id=CONF.network.public_network_id) + + for subnet in subnets['subnets']: + if (subnet['gateway_ip'] and + subnet['ip_version'] == constants.IP_VERSION_4): + return subnet['gateway_ip'] + + def _create_server_for_topology( + self, network_id=None, port_type=None, + different_host=None, port_qos_policy_id=None): + if not network_id: + network_id = self.network['id'] + if port_type: + kwargs = {'binding:vnic_type': port_type, + 'qos_policy_id': port_qos_policy_id} + port = self.create_port( + network={'id': network_id}, **kwargs) + networks = [{'port': port['id']}] + else: + networks = [{'uuid': network_id}] + + params = { + 'flavor_ref': self.flavor_ref, + 'image_ref': self.image_ref, + 'key_name': self.keypair['name'], + 'networks': networks, + 'security_groups': [ + {'name': self.secgroup['security_group']['name']}], + 'name': data_utils.rand_name(self._testMethodName) + } + if port_type == 'direct-physical': + net_vlan = self.client.show_network( + network_id)['network']['provider:segmentation_id'] + params['user_data'] = build_user_data(net_vlan) + params['config_drive'] = True + if (different_host and CONF.compute.min_compute_nodes > 1): + params['scheduler_hints'] = { + 'different_host': different_host['id']} + server = self.create_server(**params)['server'] + if different_host and CONF.compute.min_compute_nodes > 1: + if (self.get_host_for_server(different_host['id']) == + self.get_host_for_server(server['id'])): + raise self.skipException( + 'Failed to run the VM on a different hypervisor, make ' + 'sure that DifferentHostFilter is in the list of ' + 'enabled nova scheduler filters') + + port = self.client.list_ports(device_id=server['id'])['ports'][0] + if network_id == CONF.network.public_network_id: + access_ip_address = port['fixed_ips'][0]['ip_address'] + else: + access_ip_address = self.create_floatingip( + port=port)['floating_ip_address'] + + server['ssh_client'] = ssh.Client(access_ip_address, + self.username, + pkey=self.keypair['private_key']) + return server + + def _create_vms_by_topology( + self, topology='internal', port_type=None, ipv6=False, + different_host=True, num_vms_created=2): + + """Function for creating desired topology for the test + + Available topologies: + * internal(default): sender and receiver are on tenant network + * external: sender and receiver are on external(public) network + * east-west: sender and receiver are on different tenant networks + * north-south: sender is on external and receiver on tenant network + + :param topology(str): one of 4 available topologies to use (see list + above) + :param port_type(str): type of port to use. If omitted, default port + type will be used. Can be set to 'direct' or 'direct-physical' + for SR-IOV environments. + :param different_host(bool): whether to force vms to run on different + host. + :param num_vms_created(int): number of vms to create, 1 or 2. + default is 2. + :returns: sender if num_vms_created is 1, else server and receiver + """ + # num_vms_created can be 1 or 2 + self.assertIn(num_vms_created, [1, 2], "num_vms_created can be 1 or 2") + + def _create_local_network(): + network = self.create_network() + subnet_index = len(self.reserved_subnet_cidrs) + cidr = '192.168.%d.0/24' % subnet_index + subnet = self.create_subnet(network, cidr=cidr) + self.create_router_interface(router['id'], subnet['id']) + if ipv6: + ipv6_cidr = '2001:{:x}::/64'.format(200 + subnet_index) + ra_address_mode = 'dhcpv6-stateless' + ipv6_subnet = self.create_subnet( + network, cidr=ipv6_cidr, ip_version=6, + ipv6_ra_mode=ra_address_mode, + ipv6_address_mode=ra_address_mode) + self.create_router_interface(router['id'], ipv6_subnet['id']) + + return network + + if topology != 'external': + if hasattr(self, "router") and self.router: + router = self.router + else: + router = self.create_router_by_client() + + if topology == 'external' or topology == 'north-south': + external_network = self.client.show_network( + CONF.network.public_network_id)['network'] + if not external_network['shared']: + skip_reason = "External network is not shared" + self.skipTest(skip_reason) + src_network = external_network + else: + src_network = _create_local_network() + + sender = self._create_server_for_topology( + network_id=src_network['id'], + port_type=port_type) + + if topology == 'external' or topology == 'internal': + dst_network = src_network + else: + dst_network = _create_local_network() + + different_host = sender if different_host else None + if num_vms_created == 1: + return sender + receiver = self._create_server_for_topology( + different_host=different_host, network_id=dst_network['id'], + port_type=port_type) + return sender, receiver + + +class TrafficFlowTest(BaseTempestWhiteboxTestCase): + force_tenant_isolation = False + + @classmethod + @utils.requires_ext(extension="router", service="network") + def skip_checks(cls): + super(TrafficFlowTest, cls).skip_checks() + if not CONF.network.public_network_id: + raise cls.skipException( + 'The public_network_id option must be specified.') + if not WB_CONF.run_traffic_flow_tests: + raise cls.skipException( + "CONF.whitebox_neutron_plugin_options." + "run_traffic_flow_tests set to False.") + + @classmethod + def resource_setup(cls): + super(TrafficFlowTest, cls).resource_setup() + cls.gateway_external_ip = cls.get_external_gateway() + if not cls.gateway_external_ip: + raise cls.skipException("IPv4 gateway is not configured " + "for public network or public_network_id " + "is not configured.") + + def _start_captures(self, interface, filters): + for node in self.nodes: + node['capture'] = capture.TcpdumpCapture( + node['client'], interface, filters) + self.useFixture(node['capture']) + time.sleep(2) + + def _stop_captures(self): + for node in self.nodes: + node['capture'].stop() + + def check_east_west_icmp_flow( + self, dst_ip, expected_routing_nodes, expected_macs, ssh_client): + """Check that traffic routed as expected within a tenant network + Both directions are supported. + Traffic is captured on + CONF.whitebox_neutron_plugin_options.node_tunnel_interface. + Use values: + genev_sys_6081 for OVN + vxlanxx for ML2/OVS with VXLAN tunnels + for ML2/OVS with VLAN tunnels + + :param dst_ip(str): Destination IP address that we check route to + :param expected_routing_nodes(list): Hostnames of expected gateways, + nodes on tunnel interface of which we expect + to find ethernet frames with packets that we send + :param expected_macs(tuple): pair of MAC addresses of ports that we + expect to find on the captured packets + :param ssh_client(Client): SSH client object of the origin of traffic + (the one that we send traffic from) + + """ + interface = CONF.whitebox_neutron_plugin_options.node_tunnel_interface + + # create filters + if type(expected_macs) is tuple: + filters = 'icmp and ether host {0} and ether host {1}'.format( + expected_macs[0], + expected_macs[1]) + elif type(expected_macs) is list: + filters = ('"icmp and ((ether host {0} and ether host {1}) ' + 'or (ether host {2} and ether host {3}))"').format( + expected_macs[0][0], + expected_macs[0][1], + expected_macs[1][0], + expected_macs[1][1]) + else: + raise TypeError(expected_macs) + + self._start_captures(interface, filters) + self.check_remote_connectivity(ssh_client, dst_ip, ping_count=2) + self._stop_captures() + LOG.debug('Expected routing nodes: {}'.format( + ','.join(expected_routing_nodes))) + actual_routing_nodes = [node['name'] + for node in self.nodes if + not node['capture'].is_empty()] + LOG.debug('Actual routing nodes: {}'.format( + ','.join(actual_routing_nodes))) + self.assertCountEqual(expected_routing_nodes, actual_routing_nodes) + + def check_north_south_icmp_flow( + self, dst_ip, expected_routing_nodes, expected_mac, ssh_client, + ignore_outbound=False): + """Check that traffic routed as expected between internal and external + networks. Both directions are supported. + + :param dst_ip(str): Destination IP address that we check route to + :param expected_routing_nodes(list): Hostnames of expected gateways, + nodes on external interface of which we expect + to find ethernet frames with packets that we send + :param expected_mac(str): MAC address of a port that we expect to find + on the expected gateway external interface + :param ssh_client(Client): SSH client object of the origin of traffic + (the one that we send traffic from) + :param ignore_outbound(bool): Whether to ignore outbound packets. + This helps to avoid false positives. + """ + interface = WB_CONF.node_ext_interface + inbound = '-Qin' if ignore_outbound else '' + size = None + if not WB_CONF.bgp: + filters = '{} icmp and ether host {}'.format(inbound, expected_mac) + else: + filters = "{} icmp and icmp[0] == 8".format(inbound) + size = random.randint(0, 50) + # Adjust payload size adding icmp header size + if netaddr.valid_ipv6(dst_ip): + size += 44 + else: + size += 28 + # Filter including ip size packet + filters += " and ip[2:2]=={} and ip dst {}".format(size, dst_ip) + + self._start_captures(interface, filters) + # if the host is localhost, don't use remote connectivity, + # ping directly on the host + if ssh_client.host in ( + 'localhost', '127.0.0.1', '0:0:0:0:0:0:0:1', '::1'): + self.ping_ip_address(dst_ip, mtu=size, should_succeed=True) + # tcpdump requires a delay between capturing packets and writing + # them to its file. + time.sleep(2) + else: + self.check_remote_connectivity( + ssh_client, dst_ip, mtu=size, ping_count=2) + self._stop_captures() + LOG.debug('Expected routing nodes: {}'.format(expected_routing_nodes)) + actual_routing_nodes = [node['name'] + for node in self.nodes if + not node['capture'].is_empty()] + LOG.debug('Actual routing nodes: {}'.format( + ','.join(actual_routing_nodes))) + self.assertCountEqual(expected_routing_nodes, actual_routing_nodes) + class BaseTempestTestCaseOvn(BaseTempestWhiteboxTestCase): @classmethod def resource_setup(cls): super(BaseTempestTestCaseOvn, cls).resource_setup() - agents = cls.os_admin.network.AgentsClient().list_agents()['agents'] - ovn_agents = [agent for agent in agents if 'ovn' in agent['binary']] - if not ovn_agents: + if not cls.has_ovn_support: raise cls.skipException( "OVN agents not found. This test is supported only on " "openstack environments with OVN support.") @@ -189,3 +480,34 @@ class BaseTempestTestCaseOvn(BaseTempestWhiteboxTestCase): 'name=provnet-{sid}'.format(cmd=self.nbctl, sid=segment_id) output = self.run_on_master_controller(cmd) self.assertEqual(output, '') + + +# user_data_cmd is used to generate a VLAN interface on VM instances with PF +# ports +user_data_cmd = """ + #cloud-config + write_files: + - path: "/etc/sysconfig/network-scripts/ifcfg-%s" + owner: "root" + permissions: "777" + content: | + DEVICE="%s" + BOOTPROTO="dhcp" + ONBOOT="yes" + VLAN="yes" + PERSISTENT_DHCLIENT="yes" + runcmd: + - [ sh, -c , "systemctl restart NetworkManager" ] + """ +user_data_cmd = user_data_cmd.replace('\t', '') + + +def build_user_data(net_vlan): + """user_data is required when direct-physical (PF) ports are used + """ + if_full_name = '%s.%s' % \ + (WB_CONF.default_instance_interface, + net_vlan) + user_data = base64.b64encode(( + user_data_cmd % (if_full_name, if_full_name)).encode("utf-8")) + return user_data diff --git a/whitebox_neutron_tempest_plugin/tests/scenario/test_broadcast.py b/whitebox_neutron_tempest_plugin/tests/scenario/test_broadcast.py new file mode 100644 index 0000000..df93750 --- /dev/null +++ b/whitebox_neutron_tempest_plugin/tests/scenario/test_broadcast.py @@ -0,0 +1,349 @@ +# Copyright 2020 Red Hat, 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 re +import time + +import netaddr +from neutron_lib import constants +from neutron_tempest_plugin.common import ip +from neutron_tempest_plugin.common import ssh +from oslo_log import log +from tempest import config +from tempest.lib.common.utils import data_utils +from tempest.lib import decorators +from tempest.lib import exceptions + +from whitebox_neutron_tempest_plugin.tests.scenario import base + + +CONF = config.CONF +WB_CONF = CONF.whitebox_neutron_plugin_options +LOG = log.getLogger(__name__) + + +def get_capture_script(result_file, interface): + return """#!/bin/bash +export LC_ALL=en_US.UTF-8 +tcpdump -Qin -i %(interface)s -vvneA -s0 -l icmp &> %(result_file)s & + """ % {'result_file': result_file, + 'interface': interface} + + +# TODO(eolivare): create a parent class for BaseBroadcastTest and +# BaseMulticastTest with common code +class BaseBroadcastTest(object): + + vlan_transparent = False + servers = [] + + # Import configuration options + receivers_count = WB_CONF.broadcast_receivers_count + + capture_output_file = "/tmp/capture_broadcast_out" + # the following values can be used to send ping messages including once the + # text "Hi here" + broadcast_message = "Hi here" + broadcast_message_pattern = "486920686572652e" + ping_size = 23 + + @classmethod + def skip_checks(cls): + super(BaseBroadcastTest, cls).skip_checks() + advanced_image_available = ( + CONF.neutron_plugin_options.advanced_image_ref or + CONF.neutron_plugin_options.default_image_is_advanced) + if not advanced_image_available: + skip_reason = "This test require advanced tools for this test" + raise cls.skipException(skip_reason) + + @classmethod + def resource_setup(cls): + super(BaseBroadcastTest, cls).resource_setup() + + if CONF.neutron_plugin_options.default_image_is_advanced: + cls.flavor_ref = CONF.compute.flavor_ref + cls.image_ref = CONF.compute.image_ref + cls.username = CONF.validation.image_ssh_user + else: + cls.flavor_ref = ( + CONF.neutron_plugin_options.advanced_image_flavor_ref) + cls.image_ref = CONF.neutron_plugin_options.advanced_image_ref + cls.username = CONF.neutron_plugin_options.advanced_image_ssh_user + + # setup basic topology for servers we can log into it + try: + if cls.vlan_transparent: + cls.network = cls.create_network( + vlan_transparent=cls.vlan_transparent) + else: + cls.network = cls.create_network() + except exceptions.ServerFault as exc: + msg = 'Backend does not support VLAN Transparency.' + if exc.resp_body['message'] == msg: + raise cls.skipException(msg) + else: + raise exc + cls.subnet = cls.create_subnet(cls.network) + cls.router = cls.create_router_by_client() + cls.create_router_interface(cls.router['id'], cls.subnet['id']) + + cls.keypair = cls.create_keypair() + + cls.secgroup = cls.os_primary.network_client.create_security_group( + name='secgroup_bcast') + cls.security_groups.append(cls.secgroup['security_group']) + cls.create_loginable_secgroup_rule( + secgroup_id=cls.secgroup['security_group']['id']) + cls.create_pingable_secgroup_rule( + secgroup_id=cls.secgroup['security_group']['id']) + + def _create_server(self, server_type, vlan_tag=None, scheduler_hints=None): + network_id = self.network['id'] + if self.vlan_transparent: + server_index = len(self.servers) + vlan_ipmask = self.vlan_ipmast_template.format( + ip_last_byte=server_index + 10) + allowed_address_pairs = [{'ip_address': vlan_ipmask}] + port = self.create_port( + network={'id': network_id}, + security_groups=[self.secgroup['security_group']['id']], + allowed_address_pairs=allowed_address_pairs) + networks = [{'port': port['id']}] + else: + networks = [{'uuid': network_id}] + + params = { + 'flavor_ref': self.flavor_ref, + 'image_ref': self.image_ref, + 'key_name': self.keypair['name'], + 'networks': networks, + 'security_groups': [ + {'name': self.secgroup['security_group']['name']}], + 'name': data_utils.rand_name(server_type) + } + if not (CONF.compute.min_compute_nodes > 1): + LOG.debug('number of compute nodes is not higher than 1 - ' + 'scheduler_hints will not be used') + elif scheduler_hints: + params['scheduler_hints'] = scheduler_hints + server = self.create_server(**params)['server'] + self.wait_for_server_active(server) + + port = self.client.list_ports(device_id=server['id'])['ports'][0] + access_ip_address = self.create_floatingip( + port=port)['floating_ip_address'] + + server['ssh_client'] = ssh.Client(access_ip_address, + self.username, + pkey=self.keypair['private_key']) + # enable icmp broadcast responses + server['ssh_client'].execute_script( + "sysctl net.ipv4.icmp_echo_ignore_broadcasts=0", become_root=True) + + if self.vlan_transparent: + # configure transparent vlan on server + vlan_tag = vlan_tag or self.vlan_tag + server['vlan_device'] = self._configure_vlan_transparent( + port, server['ssh_client'], vlan_ipmask, vlan_tag) + + self.servers.append(server) + + return server + + def _prepare_capture_script(self, server): + interface = server['vlan_device'] if self.vlan_transparent else 'any' + capture_script = get_capture_script( + result_file=self.capture_output_file, + interface=interface) + server['ssh_client'].execute_script( + 'echo "%s" > /tmp/capture_script.sh' % capture_script) + + def _check_broadcast_conectivity(self, sender, receivers, + nonreceivers=[], num_pings=1): + def _message_received(client, msg, file_path): + result = client.execute_script( + "cat {path} || echo '{path} not exists yet'".format( + path=file_path)) + return msg in result + + def _validate_number_of_messages(client, file_path, expected_count): + """This function validates number of packets that reached a VM + + The function compares actual received number of packets per group + with the expected number. + """ + result = client.execute_script( + "cat {path} || echo '{path} not exists yet'".format( + path=file_path)) + # We need to make sure that exactly the expected count + # of messages reached receiver, no more and no less + LOG.debug('result = {}'. format(result)) + count = len( + re.findall(self.broadcast_message, result)) + self.assertEqual( + count, expected_count, + 'Received number of messages ({}) differs ' + 'from the expected ({})'.format( + count, num_pings)) + + # tcpdump started both on receivers and nonreceivers + for server in receivers + nonreceivers: + self._prepare_capture_script(server) + server['ssh_client'].execute_script( + "bash /tmp/capture_script.sh", become_root=True) + + if not self.vlan_transparent: + cidr = self.subnet['cidr'] + else: + cidr = self.vlan_ipmast_template.format(ip_last_byte=0) + + broadcast_ip = netaddr.IPNetwork(cidr).broadcast + # waiting until tcpdump capturing on receivers + time.sleep(2) + sender['ssh_client'].execute_script( + ("ping -b %(broadcast_ip)s -s %(ping_size)d " + "-c %(num_pings)d -p %(ping_pattern)s") % { + 'broadcast_ip': broadcast_ip, + 'ping_size': self.ping_size, + 'num_pings': num_pings, + 'ping_pattern': self.broadcast_message_pattern}) + # waiting until packets reached receivers + time.sleep(2) + + # num_ping packets expected on each receiver server + for server in receivers: + server['ssh_client'].execute_script( + "killall tcpdump && sleep 2", become_root=True) + LOG.debug('Validating number of messages on ' + 'receiver {}'.format(server['id'])) + _validate_number_of_messages( + server['ssh_client'], self.capture_output_file, num_pings) + + # No packets expected on each nonreceiver server + for server in nonreceivers: + server['ssh_client'].execute_script( + "killall tcpdump && sleep 2", become_root=True) + LOG.debug('Validating number of messages on ' + 'receiver {}'.format(server['id'])) + _validate_number_of_messages( + server['ssh_client'], self.capture_output_file, 0) + + +class BroadcastTestIPv4(BaseBroadcastTest, base.TrafficFlowTest): + + # IP version specific parameters + _ip_version = constants.IP_VERSION_4 + any_addresses = constants.IPv4_ANY + + +class BroadcastTestIPv4Common(BroadcastTestIPv4): + + @decorators.idempotent_id('7f33370a-5f46-4452-8b2f-166acda1720f') + def test_broadcast_same_network(self): + """Test broadcast messaging between servers on the same network + + [Sender server] -> (Broadcast network) -> [Receiver server] + Scenario: + 1. Create VMs for sender, receiver(s) on a common internal network + and send broadcast ping messages + 2. Verify that all broadcast packets reach the other hosts + In case of vlan_transparent, broadcast packets will be captured on VLAN + interfaces + """ + sender = self._create_server('broadcast-sender') + + receivers = [] + for i in range(self.receivers_count): + # even VMs scheduled on a different compute from the sender + # odd VMs scheduled on the same compute than the sender + if i % 2 == 0: + scheduler_hints = {'different_host': sender['id']} + else: + scheduler_hints = {'same_host': sender['id']} + receivers.append(self._create_server( + 'broadcast-receiver', scheduler_hints=scheduler_hints)) + + self._check_broadcast_conectivity(sender=sender, + receivers=receivers, + num_pings=2) + + +class BroadcastTestVlanTransparency(BroadcastTestIPv4): + + required_extensions = ['vlan-transparent', 'allowed-address-pairs'] + vlan_transparent = True + vlan_tag = 123 + vlan_ipmast_template = '192.168.111.{ip_last_byte}/24' + + def _configure_vlan_transparent(self, port, ssh_client, vlan_ip, vlan_tag): + ip_command = ip.IPCommand(ssh_client=ssh_client) + for address in ip_command.list_addresses(port=port): + port_iface = address.device.name + break + else: + self.fail("Parent port fixed IP not found on server.") + + subport_iface = ip_command.configure_vlan_transparent( + port=port, vlan_tag=vlan_tag, ip_addresses=[vlan_ip]) + for address in ip_command.list_addresses(ip_addresses=vlan_ip): + self.assertEqual(subport_iface, address.device.name) + self.assertEqual(port_iface, address.device.parent) + break + else: + self.fail("Sub-port fixed IP not found on server.") + + return subport_iface + + @decorators.idempotent_id('7ea12762-31af-4bf2-9219-c54212171010') + def test_broadcast_vlan_transparency(self): + """Test broadcast messaging between servers on the same network, but + using different VLANs + + [Sender server] -> (Broadcast network) -> [Receiver server] + Scenario: + 1. Create VMs for sender, receiver(s) on a common internal network + and send broadcast ping messages + 2. Verify that all broadcast packets reach the other hosts within + a common VLAN and that receivers with different VLANs do not + receive those messages + """ + vlan_groups = (self.vlan_tag, self.vlan_tag + 1) + senders = {} + receivers = {} + for vlan_group in vlan_groups: + senders[vlan_group] = self._create_server( + 'broadcast-sender-%d' % vlan_group, vlan_tag=vlan_group) + receivers[vlan_group] = [] + for i in range(self.receivers_count): + # even VMs scheduled on a different compute from the sender + # odd VMs scheduled on the same compute than the sender + if i % 2 == 0: + scheduler_hints = { + 'different_host': senders[vlan_group]['id']} + else: + scheduler_hints = {'same_host': senders[vlan_group]['id']} + receivers[vlan_group].append( + self._create_server('broadcast-receiver-%d' % vlan_group, + vlan_tag=vlan_group, + scheduler_hints=scheduler_hints)) + + self._check_broadcast_conectivity( + sender=senders[vlan_groups[0]], + receivers=receivers[vlan_groups[0]], + nonreceivers=receivers[vlan_groups[1]], num_pings=2) + self._check_broadcast_conectivity( + sender=senders[vlan_groups[1]], + receivers=receivers[vlan_groups[1]], + nonreceivers=receivers[vlan_groups[0]], num_pings=2) diff --git a/whitebox_neutron_tempest_plugin/tests/scenario/test_multicast.py b/whitebox_neutron_tempest_plugin/tests/scenario/test_multicast.py new file mode 100644 index 0000000..9c33021 --- /dev/null +++ b/whitebox_neutron_tempest_plugin/tests/scenario/test_multicast.py @@ -0,0 +1,1113 @@ +# Copyright 2020 Red Hat, 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 re +import time + +import netaddr +from neutron_lib import constants +from neutron_lib.utils import test +from neutron_tempest_plugin.common import ip +from neutron_tempest_plugin.common import ssh +from neutron_tempest_plugin.common import utils +from neutron_tempest_plugin import exceptions +from oslo_log import log +from tempest import config +from tempest.lib.common.utils import data_utils +from tempest.lib import decorators +from tempest.lib import exceptions as temp_exc + +from whitebox_neutron_tempest_plugin.tests.scenario import base + + +CONF = config.CONF +WB_CONF = CONF.whitebox_neutron_plugin_options +LOG = log.getLogger(__name__) +PYTHON3_BIN = "python3" +INSTANCE_INTERFACE = WB_CONF.default_instance_interface + + +def get_receiver_script( + group, port, hello_message, ack_message, result_file, interface=None): + if interface: + bind_to_dev_cmd = """ +sock.setsockopt(socket.SOL_SOCKET, + socket.SO_BINDTODEVICE, + str('%(interface)s').encode('utf-8'))""" % { + 'interface': interface} + else: + bind_to_dev_cmd = '' + + return """ +import socket +import struct +import sys + +multicast_group = '%(group)s' +server_address = ('', %(port)s) + +# Create the socket +sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) +%(bind_to_dev_cmd)s + +# Bind to the server address +sock.bind(server_address) + +# Tell the operating system to add the socket to the multicast group +# on all interfaces. +group = socket.inet_aton(multicast_group) +mreq = struct.pack('4sL', group, socket.INADDR_ANY) +sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq) + +# Receive/respond loop +with open('%(result_file)s', 'w') as f: + f.write('%(hello_message)s') + f.flush() + data, address = sock.recvfrom(1024) + f.write('received ' + str(len(data)) + ' bytes from ' + str(address)) + f.write(str(data)) +sock.sendto(b'%(ack_message)s', address) + """ % {'group': group, + 'port': port, + 'hello_message': hello_message, + 'ack_message': ack_message, + 'result_file': result_file, + 'bind_to_dev_cmd': bind_to_dev_cmd} + + +def get_sender_script(group, port, message, result_file, ttl): + + return """ +import socket +import sys + +message = b'%(message)s' +multicast_group = ('%(group)s', %(port)s) + +# Create the datagram socket +sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) +# Set the time-to-live for messages to 1 so they do not go past the +# local network segment. For east-west or north-south tests recommended +# TTL is 2. +sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, %(ttl)s) + +# Set a timeout so the socket does not block indefinitely when trying +# to receive data. +sock.settimeout(1) + +with open('%(result_file)s', 'w') as f: + try: + # Send data to the multicast group + sent = sock.sendto(message, multicast_group) + + # Look for responses from all recipients + while True: + try: + data, server = sock.recvfrom(1024) + except socket.timeout: + f.write('timed out, no more responses') + break + else: + f.write('received reply ' + str(data) + ' from ' + str(server)) + finally: + sys.stdout.write('closing socket') + sock.close() + """ % {'group': group, + 'port': port, + 'message': message, + 'result_file': result_file, + 'ttl': ttl} + + +def get_capture_script(result_file, mcast_port, interface): + return """#!/bin/bash +export LC_ALL=en_US.UTF-8 +tcpdump -Qin -i %(interface)s -vvneA -s0 -l port %(mcast_port)s \ + &> %(result_file)s & + """ % {'interface': interface, + 'mcast_port': mcast_port, + 'result_file': result_file} + + +class BaseMulticastTest(object): + + credentials = ['primary', 'admin'] + force_tenant_isolation = False + vlan_transparent = False + + # Import configuration options + receivers_count = WB_CONF.mcast_receivers_count + mcast_groups_count = WB_CONF.mcast_groups_count + external_querier_period = WB_CONF.external_igmp_querier_period + + hello_message = "I am waiting..." + multicast_port = 5007 + multicast_message = "group %s" + receiver_output_file = "/tmp/receiver_mcast_out" + sender_output_file = "/tmp/sender_mcast_out_%s" + capture_output_file = "/tmp/capture_mcast_out" + + @classmethod + def skip_checks(cls): + super(BaseMulticastTest, cls).skip_checks() + advanced_image_available = ( + CONF.neutron_plugin_options.advanced_image_ref or + CONF.neutron_plugin_options.default_image_is_advanced) + if not advanced_image_available: + skip_reason = "This test require advanced tools for this test" + raise cls.skipException(skip_reason) + + @classmethod + def resource_setup(cls): + super(BaseMulticastTest, cls).resource_setup() + + if CONF.neutron_plugin_options.default_image_is_advanced: + cls.flavor_ref = CONF.compute.flavor_ref + cls.image_ref = CONF.compute.image_ref + cls.username = CONF.validation.image_ssh_user + else: + cls.flavor_ref = ( + CONF.neutron_plugin_options.advanced_image_flavor_ref) + cls.image_ref = CONF.neutron_plugin_options.advanced_image_ref + cls.username = CONF.neutron_plugin_options.advanced_image_ssh_user + + # setup basic topology for servers we can log into it + if cls.has_ovn_support: + try: + cls.network = cls.create_network( + vlan_transparent=cls.vlan_transparent) + except temp_exc.ServerFault as exc: + msg = 'Backend does not support VLAN Transparency.' + if exc.resp_body['message'] == msg: + raise cls.skipException(msg) + else: + raise exc + else: + cls.network = cls.create_network() + + cls.subnet = cls.create_subnet(cls.network) + cls.router = cls.create_router_by_client() + cls.create_router_interface(cls.router['id'], cls.subnet['id']) + + cls.keypair = cls.create_keypair() + + cls.secgroup = cls.os_primary.network_client.create_security_group( + name='secgroup_mcast') + cls.security_groups.append(cls.secgroup['security_group']) + cls.create_loginable_secgroup_rule( + secgroup_id=cls.secgroup['security_group']['id']) + cls.create_pingable_secgroup_rule( + secgroup_id=cls.secgroup['security_group']['id']) + # Allow receiving of IGMP queries by VMs + cls.create_security_group_rule( + security_group_id=cls.secgroup['security_group']['id'], + protocol=constants.PROTO_NAME_IGMP, + direction=constants.INGRESS_DIRECTION) + # Create security group rule for UDP (multicast traffic) + cls.create_secgroup_rules( + rule_list=[dict(protocol=constants.PROTO_NAME_UDP, + direction=constants.INGRESS_DIRECTION, + remote_ip_prefix=cls.any_addresses, + ethertype=cls.ethertype)], + secgroup_id=cls.secgroup['security_group']['id']) + + # Multicast IP range to be used for multicast group IP asignement + if '-' in cls.multicast_group_range: + multicast_group_range = netaddr.IPRange( + *cls.multicast_group_range.split('-')) + else: + multicast_group_range = netaddr.IPNetwork( + cls.multicast_group_range) + cls.multicast_group_iter = iter(multicast_group_range) + + # For external or north-south topologies external network + # need to be defined + cls.external_network = cls.client.show_network( + CONF.network.public_network_id)['network'] + + def _check_cmd_installed_on_server(self, ssh_client, server_id, cmd): + try: + ssh_client.execute_script('which %s' % cmd) + except exceptions.SSHScriptFailed: + raise self.skipException( + "%s is not available on server %s" % (cmd, server_id)) + + def _prepare_sender(self, server, mcast_groups, ttl): + for mcast_group in mcast_groups: + output_file = self.sender_output_file % mcast_group + check_script = get_sender_script( + group=mcast_group, port=self.multicast_port, + message=self.multicast_message % mcast_group, + result_file=output_file, + ttl=ttl) + server['ssh_client'].execute_script( + 'echo "%s" > /tmp/multicast_traffic_sender_%s.py' % + (check_script, mcast_group)) + + def _prepare_capture_script(self, server): + interface = (server['vlan_device'] if self.vlan_transparent + else INSTANCE_INTERFACE) + capture_script = get_capture_script( + result_file=self.capture_output_file, + mcast_port=self.multicast_port, interface=interface) + self._check_cmd_installed_on_server( + server['ssh_client'], server['id'], 'tcpdump') + server['ssh_client'].execute_script( + 'echo "%s" > /tmp/capture_script.sh' % capture_script) + + def _prepare_receiver(self, server, mcast_address): + interface = server['vlan_device'] if self.vlan_transparent else None + check_script = get_receiver_script( + group=mcast_address, port=self.multicast_port, + hello_message=self.hello_message, ack_message=server['id'], + result_file=self.receiver_output_file, interface=interface) + server['ssh_client'].execute_script( + 'echo "%s" > /tmp/multicast_traffic_receiver.py' % check_script) + self._prepare_capture_script(server) + + def _prepare_unregistered(self, server): + # We need to kill multicast receiver script if receiver becomes + # unsubscribed and the server moves to the pool of unregistered VMs + server['ssh_client'].exec_command( + "pids=$(ps ax | grep multicast | grep -v grep | awk '{print $1}')" + ";kill -9 $pids && sleep 2 || true") + self._prepare_capture_script(server) + + def _prepare_igmp_snooping_test( + self, mcast_groups, receivers_count=1, topology='internal', + port_type=None): + """Function for creating desired topology for the test + + Available topologies: + * internal(default): sender and receivers are on tenant network + * external: sender and receivers are on external(public) network + * east-west: sender and receivers are on different tenant networks + * north-south: sender is on external and receivers on tenant network + + :param mcast_groups(list): list of multicast groups to use. Note, + for each group at least one receiver will be created. + :param receivers_count(int): Number of receivers to create for each + multicast group. Note, first receiver is always created on a + different compute node than sender is running. + :param topology(str): one of 4 available topologies to use (see list + above) + :param port_type(str): type of port to use. If omitted, default port + type will be used. Can be set to 'direct' or 'direct-physical' + for SR-IOV environments. + + :returns: List of the following objects, sender, list of receivers, + list of unregistered, id of destination network + """ + # Note, for now we support port_type other than 'normal' only + # when using SR-IOV environement and therefore only external network + if topology == 'external' or topology == 'north-south': + if not self.external_network['shared']: + skip_reason = "External network is not shared" + self.skipTest(skip_reason) + sender = self._create_server_for_topology( + network_id=self.external_network['id'], + port_type=port_type) + elif topology == 'east-west': + network2 = self.create_network() + subnet2 = self.create_subnet(network2, cidr="10.100.1.0/24") + self.create_router_interface(self.router['id'], subnet2['id']) + sender = self._create_server_for_topology( + network_id=network2['id'], port_type=port_type) + else: + sender = self._create_server_for_topology(port_type=port_type) + + if topology == 'external': + dst_network_id = self.external_network['id'] + else: + dst_network_id = self.network['id'] + + receivers = [] + for group_id in range(len(mcast_groups)): + if receivers_count > 0: + # At least one receiver in the group we need to be launched + # on different host + receivers.append( + [self._create_server_for_topology( + different_host=sender, network_id=dst_network_id, + port_type=port_type)]) + for _ in range(receivers_count - 1): + receivers[group_id].append( + self._create_server_for_topology( + network_id=dst_network_id, port_type=port_type)) + else: + # For some tests we do not create receivers + receivers.append([]) + + servers = [item for sublist in receivers for item in sublist] + servers.append(sender) + + unregistered = self._create_server_for_topology( + network_id=dst_network_id, port_type=port_type) + servers.append(unregistered) + # For some tests we need to support more than one unregistered server + unregistered = [unregistered] + + for server in servers: + self._check_cmd_installed_on_server(server['ssh_client'], + server['id'], PYTHON3_BIN) + return [sender, receivers, unregistered, dst_network_id] + + def restart_openvswitch_on_compute_nodes(self): + for node in self.nodes: + if node['type'] == 'compute': + node['client'].exec_command( + "sudo systemctl restart ovs-vswitchd.service") + time.sleep(20) + + def _is_multicast_traffic_expected(self, mcast_address): + """Checks if multicast traffic is expected to arrive. + + Checks if multicast traffic is expected to arrive to the + unregistered VM. + + If IGMP snooping is enabled, multicast traffic should not be + flooded unless the destination IP is in the range of 224.0.0.X + [0]. + + [0] https://tools.ietf.org/html/rfc4541 (See section 2.1.2) + """ + return (str(mcast_address).startswith('224.0.0') or not + CONF.neutron_plugin_options.is_igmp_snooping_enabled) + + def _check_multicast_conectivity( + self, mcast_groups, sender, receivers, unregistered, + test_unsubscribe=False, start_delay=None, pre_action=None, ttl=1): + """Test multicast messaging between servers + + [Sender server] -> ... some network topology ... -> [Receiver server] + + :param mcast_groups(list): list of multicast groups to use. + Note: for each group will be created a sender script and at least + one receiver. + :param sender(server): server that will send multicast traffic + :param receivers(list): list of lists of servers. There is ia list of + receivers per group. + :param unregistered(list): list of servers that are not subscribed + to receive multicast traffic. + :param test_unsubscribe(boolean): whether to test unsubsribe step or + not. Default is False. + :param start_delay(int): whether to include delay waiting step before + sending packets and how long (in seconds). Default is no delay. + :param pre_action(callable): run additional arbitrary action before + sending multicast packets. + :param ttl(int): time-to-live for multicast packages. Default is 1 fits + well when all VMs are on the same network. For testing multicast + between 2 different network TTL should be set to 2. + + Scenario is the following: + 1. Install required scripts on all VMs according their roles. + 2. Send multicast packets from sender to each multicast group. + Note: by default a single group is used and single receiver, + but this can be adjusted with config options. + 3. Verify that multicast traffic reached each receiver and + does not reach unregistgered host. Number of received packets + is the same as sent number. + 4. Unsubscribe one receiver in each group and repeat steps 2 and 3. + Note: in case more than one receiver configured the step will + repeat until no subscribed hosts left. + + """ + def _message_received(client, msg, file_path): + result = client.execute_script( + "cat {path} || echo '{path} not exists yet'".format( + path=file_path)) + return msg in result + + def _validate_number_of_messages( + client, mcast_groups, file_path, allowed_group=None): + """This function validates number of packets that reached a VM + + The function compares actual received number of packets per group + with the expected number. + """ + result = client.execute_script( + "cat {path} || echo '{path} not exists yet'".format( + path=file_path)) + # We need to make sure that exactly the expected count + # of messages reached receiver, no more and no less + LOG.debug('result = {}'. format(result)) + for mcast_group in mcast_groups: + LOG.debug('Validating group {}'.format(mcast_group)) + if allowed_group: + LOG.debug('Allowed group {}'.format(allowed_group)) + if ((allowed_group and mcast_group == allowed_group) or + self._is_multicast_traffic_expected(mcast_group)): + expected_count = 1 + else: + expected_count = 0 + count = len( + re.findall(self.multicast_message % mcast_group, result)) + self.assertEqual( + count, expected_count, + 'Received number of messages ({}) to group {} differs ' + 'from the expected ({})'.format( + count, mcast_group, expected_count)) + + def _test_traffic_between_servers(): + for server in unregistered: + self._prepare_unregistered(server) + server['ssh_client'].execute_script( + "bash /tmp/capture_script.sh", become_root=True) + + receiver_ids = [] + # receivers is a list of separate lists for each mcast group + group_ids = range(len(mcast_groups)) + for group_id in group_ids: + receiver_ids.append([]) + for receiver in receivers[group_id]: + self._prepare_receiver( + receiver, mcast_groups[group_id]) + # We run the capture script on each receiver as well + # in order to check that there is no traffic from any + # group that the receiver did not subscribe to + receiver['ssh_client'].execute_script( + "bash /tmp/capture_script.sh", become_root=True) + + # receiver script needs to be executed as root user for + # vlan_transparency in order to bind the vlan interface + receiver['ssh_client'].execute_script( + "%s /tmp/multicast_traffic_receiver.py &" % + PYTHON3_BIN, shell="bash", + become_root=self.vlan_transparent) + utils.wait_until_true( + lambda: _message_received( + receiver['ssh_client'], self.hello_message, + self.receiver_output_file), + exception=RuntimeError( + "Receiver script didn't start properly on server " + "{!r}.".format(receiver['id']))) + receiver_ids[group_id].append(receiver['id']) + + if pre_action: + pre_action() + + if start_delay and start_delay > 5: + LOG.debug( + "Waiting {} seconds for start delay to expire".format( + start_delay)) + time.sleep(start_delay) + else: + # (rsafrono) Note, this delay is needed to make sure all + # receivers are ready. In some cases (e.g. SR-IOV) test result + # is unstable if we start to send traffic without this delay. + time.sleep(5) + + for group in mcast_groups: + LOG.debug("Starting script for group {} on " + "sender".format(group)) + sender['ssh_client'].execute_script( + "%s /tmp/multicast_traffic_sender_%s.py" % ( + PYTHON3_BIN, group)) + + for group_id in group_ids: + for receiver in receivers[group_id]: + utils.wait_until_true( + lambda: _message_received( + receiver['ssh_client'], + self.multicast_message % mcast_groups[group_id], + self.receiver_output_file), + exception=RuntimeError( + "Receiver {!r} didn't get multicast " + "message".format(receiver['id']))) + + receiver['ssh_client'].execute_script( + "killall tcpdump && sleep 2", become_root=True) + LOG.debug('Validating number of messages on ' + 'receiver {}'.format(receiver['id'])) + _validate_number_of_messages( + receiver['ssh_client'], mcast_groups, + self.capture_output_file, mcast_groups[group_id]) + + for group_id in group_ids: + sender_output = ( + self.sender_output_file % mcast_groups[group_id]) + replies_result = sender['ssh_client'].execute_script( + "cat {path} || echo '{path} not exists yet'".format( + path=sender_output)) + for receiver_id in receiver_ids[group_id]: + self.assertIn(receiver_id, replies_result) + + for server in unregistered: + server['ssh_client'].execute_script( + "killall tcpdump && sleep 2", become_root=True) + LOG.debug('Validating number of messages on ' + 'unregistered {}'.format(server['id'])) + _validate_number_of_messages( + server['ssh_client'], mcast_groups, + self.capture_output_file) + + def _unsubscribe_receiver_from_group(group_id): + if len(receivers[group_id]) > 0: + unregistered.append(receivers[group_id][-1]) + del receivers[group_id][-1] + + self._prepare_sender(sender, mcast_groups, ttl) + _test_traffic_between_servers() + # If test_unsubscribe option is enabled then we unsubscribe + # one receiver from each group and retest traffic. + # Repeat this step until all receivers are unsubscribed. + if test_unsubscribe: + while len(receivers[0]) > 0: + for group_id in range(len(mcast_groups)): + _unsubscribe_receiver_from_group(group_id) + _test_traffic_between_servers() + + +class MulticastTestIPv4(BaseMulticastTest, base.TrafficFlowTest): + + # Import configuration options + multicast_group_range = CONF.neutron_plugin_options.multicast_group_range + + # IP version specific parameters + _ip_version = constants.IP_VERSION_4 + any_addresses = constants.IPv4_ANY + + +class MulticastTestIPv4Common(MulticastTestIPv4): + @decorators.idempotent_id('615a35d3-642e-4440-a19e-351f29d0b3ff') + def test_igmp_snooping_same_network_and_unsubscribe(self): + """Test multicast messaging between servers on the same network + + [Sender server] -> (Multicast network) -> [Receiver server] + Scenario: + 1. Create VMs for sender, receiver(s), unregistered host. + Note: default(internal, same network) topology is used. + mcast_groups contain only groups where no flooding + to all ports expected. + 2. Verify that multicast packets reach subscribed hosts + and do not reach unsubscribed. For more details see + _check_multicast_connectivity document text and code. + """ + mcast_groups = [next(self.multicast_group_iter) + for _ in range(self.mcast_groups_count)] + sender, receivers, unregistered, _ = self._prepare_igmp_snooping_test( + mcast_groups=mcast_groups, + receivers_count=self.receivers_count) + self._check_multicast_conectivity( + mcast_groups=mcast_groups, sender=sender, receivers=receivers, + unregistered=unregistered, test_unsubscribe=True) + + @decorators.idempotent_id('fcd50103-eeba-475f-803b-dfc9e8c342a5') + def test_igmp_snooping_ext_network_and_unsubscribe(self): + """Test multicast between VMs on external network + + [Sender server] -> (Multicast network) -> [Receiver server] + Scenario: + 1. Create VMs for sender, receiver(s), unregistered host. + Note: External topology is used. + mcast_groups contain only groups where no flooding + to all ports expected. + 2. Verify that multicast packets reach subscribed hosts + and do not reach unsubscribed. For more details see + _check_multicast_connectivity document text and code. + """ + mcast_groups = [next(self.multicast_group_iter) + for _ in range(self.mcast_groups_count)] + sender, receivers, unregistered, _ = self._prepare_igmp_snooping_test( + mcast_groups=mcast_groups, topology='external', + receivers_count=self.receivers_count) + self._check_multicast_conectivity( + mcast_groups=mcast_groups, sender=sender, receivers=receivers, + unregistered=unregistered, test_unsubscribe=True) + + @decorators.idempotent_id('35523bd5-4b35-4e6b-a25a-20826808d85d') + def test_flooding_when_special_groups(self): + """Test that multicast traffic of groups 224.0.0.X is flooded + + Scenario: + 1. Create VMs for sender, receiver(s), unregistered host. + Note: default(internal, same network) topology is used. + mcast_groups contains only groups from the range 224.0.0.X + 2. Verify that multicast packets reach all VMs, even + unregistered ones. + """ + mcast_groups = ['224.0.0.100'] + sender, receivers, unregistered, _ = self._prepare_igmp_snooping_test( + mcast_groups=mcast_groups) + self._check_multicast_conectivity( + mcast_groups=mcast_groups, + sender=sender, receivers=receivers, unregistered=unregistered) + + @decorators.idempotent_id('42bfc8e1-0efb-4573-bb4a-290060a40980') + def test_igmp_snooping_after_openvswitch_restart(self): + """Test IGMP snooping after openvswitch restart + + [Sender VM] -> (Multicast network) -> [Receiver VM] + + Note: this test should not be run with other tests in parallel + because it can affect other tests results. + + Scenario: + 1. Create VMs for sender, receiver(s), unregistered host. + mcast_groups contain only groups where no flooding + to all ports expected. + 2. Subscribe receiver VMs to a multicast group but do not + send multicast packets immediately. + 3. Restart Openvswitch on compute nodes. + 4. Send multicast traffic and verify that multicast packets + reach subscribed hosts and do not reach unsubscribed. For more + details see _check_multicast_connectivity document text and code. + """ + if not hasattr(self, 'nodes'): + raise self.skipException( + "Nodes info not available. Test won't be able to restart " + "openvswitch service on a node.") + mcast_groups = [next(self.multicast_group_iter) + for _ in range(self.mcast_groups_count)] + sender, receivers, unregistered, _ = self._prepare_igmp_snooping_test( + mcast_groups=mcast_groups, + receivers_count=self.receivers_count) + self._check_multicast_conectivity( + mcast_groups=mcast_groups, sender=sender, receivers=receivers, + unregistered=unregistered, + pre_action=self.restart_openvswitch_on_compute_nodes) + + +class MulticastTestIPv4Sriov(MulticastTestIPv4): + + @classmethod + def resource_setup(cls): + super(MulticastTestIPv4Sriov, cls).resource_setup() + if not cls.has_sriov_support: + skip_reason = "Environment does not support SR-IOV" + raise cls.skipException(skip_reason) + + def _validate_pfs_availability(self): + required_sriov_ports = ( + self.mcast_groups_count * self.receivers_count + 2) + available_sriov_ports = ( + WB_CONF.sriov_pfs_per_host * + len([node for node in self.nodes + if node['type'] == 'compute'])) + if (available_sriov_ports < required_sriov_ports): + self.skipTest( + 'Not enough SR-IOV ports ({}), while required {}'.format( + available_sriov_ports, required_sriov_ports)) + + +class MulticastTestIPv4SriovCommon(MulticastTestIPv4Sriov): + + @decorators.idempotent_id('4210438f-080b-4254-a7d1-166144f04572') + def test_igmp_snooping_ext_network_with_vf_ports(self): + """Test multicast between VMs with SR-IOV VF ports on external network + + [Sender with VF port] -> (External network) -> [Receiver with VF port] + Note: the test runs only on SR-IOV environment and requires shared + external network. + + Scenario: + 1. Create VMs for sender, receiver(s), unregistered host. + Note: External topology is used. External network must be shared. + mcast_groups contain only groups where no flooding + to all ports expected. + 2. Verify that multicast packets reach subscribed hosts + and do not reach unsubscribed. For more details see + _check_multicast_connectivity document text and code. + """ + mcast_groups = [next(self.multicast_group_iter) + for _ in range(self.mcast_groups_count)] + sender, receivers, unregistered, _ = self._prepare_igmp_snooping_test( + mcast_groups=mcast_groups, topology='external', + port_type='direct', receivers_count=self.receivers_count) + self._check_multicast_conectivity( + mcast_groups=mcast_groups, sender=sender, receivers=receivers, + unregistered=unregistered, test_unsubscribe=True) + + @decorators.idempotent_id('86fb3e4b-5e01-4953-99a9-4f85c561f57e') + def test_igmp_snooping_ext_network_with_pf_ports(self): + """Test multicast between VMs with SR-IOV PF ports on external network + + [Sender with PF port] -> (External network) -> [Receiver with PF port] + Note: the test runs only on SR-IOV environment and requires shared + external network. + + Scenario: + 1. Create VMs for sender, receiver(s), unregistered host. + Note: External topology is used. External network must be shared. + mcast_groups contain only groups where no flooding + to all ports expected. + 2. Verify that multicast packets reach subscribed hosts + and do not reach unsubscribed. For more details see + _check_multicast_connectivity document text and code. + """ + mcast_groups = [next(self.multicast_group_iter) + for _ in range(self.mcast_groups_count)] + self._validate_pfs_availability() + sender, receivers, unregistered, _ = self._prepare_igmp_snooping_test( + mcast_groups=mcast_groups, topology='external', + port_type='direct-physical', receivers_count=self.receivers_count) + self._check_multicast_conectivity( + mcast_groups=mcast_groups, sender=sender, receivers=receivers, + unregistered=unregistered, test_unsubscribe=True) + + +class MulticastTestIPv4OvnBase(MulticastTestIPv4, base.BaseTempestTestCaseOvn): + mcast_idle_timeout = 60 + + # We will use this function in order to decrease the idle timeout from the + # default value in order to save some time + def set_mcast_idle_timeout(self, network_id): + uuid = self.get_item_uuid( + db='nb', item='logical_switch', + search_string='name=neutron-' + network_id) + cmd = self.nbctl + " set logical_switch " + uuid + " other_config:" + self.run_on_master_controller( + cmd + "mcast_idle_timeout=" + str(self.mcast_idle_timeout)) + + def set_querier_on_network(self, network_id): + port = self.client.list_ports( + network_id=network_id, + device_owner=constants.DEVICE_OWNER_DHCP)['ports'] + eth_src = port[0]['mac_address'] + ip4_src = port[0]['fixed_ips'][0]['ip_address'] + uuid = self.get_item_uuid( + db='nb', item='logical_switch', + search_string='name=neutron-' + network_id) + cmd = self.nbctl + " set logical_switch " + uuid + " other_config:" + self.run_on_master_controller( + cmd + "mcast_querier='true';" + + cmd + "mcast_eth_src=" + eth_src + ";" + + cmd + "mcast_ip4_src=" + ip4_src) + + def set_mcast_relay_on_router(self, router_id, state='true'): + uuid = self.get_item_uuid( + db='nb', item='logical_router', + search_string='name=neutron-' + router_id) + self.run_on_master_controller( + self.nbctl + " set logical_router " + uuid + + " options:mcast_relay=" + state) + + def restart_ovn_controller_on_compute_nodes(self): + for node in self.nodes: + if node['type'] == 'compute': + node['client'].exec_command( + "sudo podman restart ovn_controller") + time.sleep(10) + + +class MulticastTestIPv4Ovn(MulticastTestIPv4OvnBase): + + @test.unstable_test("querier not available by default, see RHBZ 1791815") + @decorators.idempotent_id('fa082cf9-37fc-4e7f-bfdb-fbd8e6860634') + def test_multicast_after_idle_timeout(self): + """Test multicast messaging after idle timeout + + [Sender VM] -> (Multicast network) -> [Receiver VM] + + Scenario: + 1. Create VMs for sender, receiver(s), unregistered host. + mcast_groups contain only groups where no flooding + to all ports expected. + 2. Subscribe receiver VMs to a multicast group but do not send + multicast packets immediately. Wait first until idle_timeout + expires and send packets afterwards. + 3. Verify that multicast packets reach subscribed hosts + and do not reach unsubscribed. For more details see + _check_multicast_connectivity document text and code. + """ + mcast_groups = [next(self.multicast_group_iter) + for _ in range(self.mcast_groups_count)] + sender, receivers, unregistered, dst_network_id = ( + self._prepare_igmp_snooping_test( + mcast_groups=mcast_groups, + receivers_count=self.receivers_count)) + self.set_mcast_idle_timeout(dst_network_id) + self._check_multicast_conectivity( + mcast_groups=mcast_groups, sender=sender, receivers=receivers, + unregistered=unregistered, start_delay=self.mcast_idle_timeout) + + # (rsafrono) Note, this test is temporary. It includes enabling querier + # as a workaround because currently the querier is not enabled by default, + # see RHBZ 1791815 + @test.unstable_test("Multicast querier not yet supported officially") + @decorators.idempotent_id('be5e153d-1ce7-4d19-9efd-2aae0ec74749') + def test_idle_timeout_with_querier_enabled(self): + """Test multicast messaging after idle timeout when querier is enabled + + [Sender VM] -> (Multicast network) -> [Receiver VM] + + Scenario: + 1. Create VMs for sender, receiver(s), unregistered host. + mcast_groups contain only groups where no flooding + to all ports expected. + 2. Enable querier on the network. Subscribe receiver VMs to + a multicast group but do not send multicast packets immediately. + Wait first until idle_timeout expires and send afterwards. + 3. Verify that multicast packets reach subscribed hosts + and do not reach unsubscribed. For more details see + _check_multicast_connectivity document text and code. + """ + + mcast_groups = [next(self.multicast_group_iter) + for _ in range(self.mcast_groups_count)] + sender, receivers, unregistered, dst_network_id = ( + self._prepare_igmp_snooping_test( + mcast_groups=mcast_groups, + receivers_count=self.receivers_count)) + self.set_querier_on_network(dst_network_id) + self.set_mcast_idle_timeout(dst_network_id) + self._check_multicast_conectivity( + mcast_groups=mcast_groups, sender=sender, receivers=receivers, + unregistered=unregistered, start_delay=self.mcast_idle_timeout) + + # (rsafrono) Note, this test includes enabling mcast_relay as + # a workaround. Currently mcast_relay is not enabled on router by default. + @test.unstable_test("mcast relay not yet supported officially") + @decorators.idempotent_id('3a906cd8-e27a-40a7-a369-829a7ec91af6') + def test_multicast_east_west(self): + """Test multicast between servers on different tenant networks + + [Sender VM] [Receiver VM] + | | + [Internal network A] -> [Router] -> [Internal network B] + + Scenario: + 1. Create VMs for sender, receiver(s), unregistered host. + Note: East-west topology is used (2 internal networks) + mcast_groups contain only groups where no flooding + to all ports expected. + 2. Verify that multicast packets reach subscribed hosts + and do not reach unsubscribed. For more details see + _check_multicast_connectivity document text and code. + """ + self.set_mcast_relay_on_router(self.router['id']) + mcast_groups = [next(self.multicast_group_iter) + for _ in range(self.mcast_groups_count)] + sender, receivers, unregistered, _ = ( + self._prepare_igmp_snooping_test( + mcast_groups=mcast_groups, topology='east-west', + receivers_count=self.receivers_count)) + self._check_multicast_conectivity( + mcast_groups=mcast_groups, sender=sender, receivers=receivers, + unregistered=unregistered, ttl=2) + + # (rsafrono) Note, this test includes enabling mcast_relay as + # a workaround. Currently mcast_relay is not enabled on router by default. + @test.unstable_test("Core OVN bug, see RHBZ 1902075") + @decorators.idempotent_id('71abfc10-3f6c-4096-a1d3-8fd934b5e3ba') + def test_multicast_north_south(self): + """Test multicast between VMs on external and internal networks + + [Sender VM] -> [External network] + | + [Router] + | + [Receiver VM] <- [Internal network] + + Note: the test requires shared external network. + Scenario: + 1. Create VMs for sender, receiver(s), unregistered host. + Note: North-south topology is used where sender is on + the external network and receivers on the internal one. + mcast_groups contain only groups where no flooding + to all ports expected. + 2. Verify that multicast packets reach subscribed hosts + and do not reach unsubscribed. For more details see + _check_multicast_connectivity document text and code. + """ + self.set_mcast_relay_on_router(self.router['id']) + mcast_groups = [next(self.multicast_group_iter) + for _ in range(self.mcast_groups_count)] + sender, receivers, unregistered, _ = ( + self._prepare_igmp_snooping_test( + mcast_groups=mcast_groups, topology='north-south', + receivers_count=self.receivers_count)) + self._check_multicast_conectivity( + mcast_groups=mcast_groups, sender=sender, receivers=receivers, + unregistered=unregistered, ttl=2) + + +class MulticastTestIPv4SriovOvn( + MulticastTestIPv4Sriov, MulticastTestIPv4OvnBase): + + # (rsafrono) External IGMP querier required for this test. + # For now the only reliable option for this is to use SR-IOV environment. + @decorators.idempotent_id('fb56ac55-7863-44f9-b0e4-3383093838a2') + def test_after_ovn_controller_restart_with_external_querier(self): + """Test IGMP snooping after ovn controller restart + + [Sender VM] -> (Multicast network) -> [Receiver VM] + + Notes: + 1. External IGMP querier required for this test to run properly. + 2. This test should not be run with other tests in parallel + because it can affect other tests results. + + Scenario: + 1. Create VMs for sender, receiver(s), unregistered host. + mcast_groups contain only groups where no flooding + to all ports expected. + 2. Subscribe receiver VMs to a multicast group but do not + send multicast packets immediately. + 3. Restart OVN controller on compute nodes. This will cause that + OVN controller unlearns the multicast groups. + 4. Wait until external querier send new queries, receivers + VMs will respond to queries and OVN controller will re-learn + the multicast groups. + 5. Send multicast traffic and verify that multicast packets + reach subscribed hosts and do not reach unsubscribed. For more + details see _check_multicast_connectivity document text and code. + """ + + mcast_groups = [next(self.multicast_group_iter) + for _ in range(self.mcast_groups_count)] + sender, receivers, unregistered, dst_network_id = ( + self._prepare_igmp_snooping_test( + port_type='direct', + mcast_groups=mcast_groups, topology='external', + receivers_count=self.receivers_count)) + self._check_multicast_conectivity( + mcast_groups=mcast_groups, sender=sender, receivers=receivers, + unregistered=unregistered, + start_delay=self.external_querier_period, + pre_action=self.restart_ovn_controller_on_compute_nodes) + + +class MulticastTestVlanTransparency(MulticastTestIPv4): + + required_extensions = ['vlan-transparent', 'allowed-address-pairs'] + vlan_transparent = True + vlan_tag = 123 + vlan_ipmast_template = '192.168.123.{ip_last_byte}/24' + + # initialize server index: this index will be used for allowed_address_pair + # values + server_index = 0 + + def _prepare_igmp_snooping_test_vlan_transparency( + self, mcast_groups, receivers_count=1, topology='internal', + port_type=None): + + sender = self._create_multicast_server_vlan_transparency(mcast_groups) + + receivers = [] + for group_id in range(len(mcast_groups)): + if receivers_count > 0: + # At least one receiver in the group we need to be launched + # on different host + receivers.append( + [self._create_multicast_server_vlan_transparency( + mcast_groups, different_host=sender)]) + for _ in range(receivers_count - 1): + receivers[group_id].append( + self._create_multicast_server_vlan_transparency( + mcast_groups)) + else: + # For some tests we do not create receivers + receivers.append([]) + + servers = [item for sublist in receivers for item in sublist] + servers.append(sender) + + unregistered = self._create_multicast_server_vlan_transparency( + mcast_groups) + servers.append(unregistered) + # For some tests we need to support more than one unregistered server + unregistered = [unregistered] + + for server in servers: + self._check_cmd_installed_on_server(server['ssh_client'], + server['id'], PYTHON3_BIN) + return [sender, receivers, unregistered] + + def _create_multicast_server_vlan_transparency( + self, mcast_groups, different_host=None): + vlan_ipmask = self.vlan_ipmast_template.format( + ip_last_byte=self.server_index + 10) + self.server_index += 1 + allowed_address_pairs = [{'ip_address': vlan_ipmask}] + port = self.create_port( + network={'id': self.network['id']}, + security_groups=[self.secgroup['security_group']['id']], + allowed_address_pairs=allowed_address_pairs) + networks = [{'port': port['id']}] + + params = { + 'flavor_ref': self.flavor_ref, + 'image_ref': self.image_ref, + 'key_name': self.keypair['name'], + 'networks': networks, + 'security_groups': [ + {'name': self.secgroup['security_group']['name']}], + 'name': data_utils.rand_name('multicast-server-vlan-transparent') + } + if (different_host and CONF.compute.min_compute_nodes > 1): + params['scheduler_hints'] = { + 'different_host': different_host['id']} + server = self.create_server(**params)['server'] + + # create fip + access_ip_address = self.create_floatingip( + port=port)['floating_ip_address'] + server['ssh_client'] = ssh.Client(access_ip_address, + self.username, + pkey=self.keypair['private_key']) + + # configure transparent vlan on server + server['vlan_device'] = self._configure_vlan_transparent( + port, server['ssh_client'], vlan_ipmask, mcast_groups) + + return server + + def _configure_vlan_transparent( + self, port, ssh_client, vlan_ip, mcast_groups): + ip_command = ip.IPCommand(ssh_client=ssh_client) + for address in ip_command.list_addresses(port=port): + port_iface = address.device.name + break + else: + self.fail("Parent port fixed IP not found on server.") + + subport_iface = ip_command.configure_vlan_transparent( + port=port, vlan_tag=self.vlan_tag, ip_addresses=[vlan_ip]) + for address in ip_command.list_addresses(ip_addresses=vlan_ip): + self.assertEqual(subport_iface, address.device.name) + self.assertEqual(port_iface, address.device.parent) + break + else: + self.fail("Sub-port fixed IP not found on server.") + + for mcast_group in mcast_groups: + ip_command.add_route(mcast_group, subport_iface) + + return subport_iface + + @decorators.idempotent_id('c480cec8-3ca4-4781-baad-2e1190079467') + def test_igmp_snooping_vlan_transparency(self): + """Test multicast messaging between servers on the same network + + [Sender server] -> (Multicast network) -> [Receiver server] + Scenario: + 1. Create VMs for sender, receiver(s), unregistered host. + Note: default(internal, same network) topology is used. + mcast_groups contain only groups where no flooding + to all ports expected. + 2. Verify that multicast packets reach subscribed hosts + and do not reach unsubscribed. For more details see + _check_multicast_connectivity document text and code. + """ + mcast_groups = [next(self.multicast_group_iter) + for _ in range(self.mcast_groups_count)] + sender, receivers, unregistered = ( + self._prepare_igmp_snooping_test_vlan_transparency( + mcast_groups=mcast_groups, + receivers_count=self.receivers_count)) + self._check_multicast_conectivity( + mcast_groups=mcast_groups, sender=sender, receivers=receivers, + unregistered=unregistered, test_unsubscribe=False) diff --git a/zuul.d/master_jobs.yaml b/zuul.d/master_jobs.yaml index 552163e..9c76821 100644 --- a/zuul.d/master_jobs.yaml +++ b/zuul.d/master_jobs.yaml @@ -13,7 +13,7 @@ - x/whitebox-neutron-tempest-plugin - openstack/tempest vars: - tempest_concurrency: 4 # out of 4 + tempest_concurrency: 2 # out of 4 tox_envlist: all # NOTE(slaweq): in case of some tests, which requires advanced image, # default test timeout set to 1200 seconds may be not enough if job is @@ -196,6 +196,9 @@ image_is_advanced: true available_type_drivers: flat,geneve,vlan,gre,local,vxlan provider_net_base_segm_id: 1 + whitebox_neutron_plugin_options: + run_traffic_flow_tests: True + broadcast_receivers_count: 1 group-vars: subnode: devstack_plugins: