diff --git a/devstack/plugin.sh b/devstack/plugin.sh index 2b377d1..f3f90e0 100644 --- a/devstack/plugin.sh +++ b/devstack/plugin.sh @@ -38,6 +38,12 @@ customize_public_network_and_subnet(){ # that only a single DHCP server is available on the external network. openstack network set --share $PUBLIC_NETWORK_NAME openstack subnet set --dhcp $PUBLIC_SUBNET_NAME + + # Decrease external network MTU to allow tests from test_mtu.py to run + # (rsafrono) tests from test_mtu.py can not be run due to + # https://bugs.launchpad.net/neutron/+bug/2060828 + # The line should be uncommented once the issue is fixed + # openstack network set --mtu 1330 $PUBLIC_NETWORK_NAME fi } diff --git a/whitebox_neutron_tempest_plugin/config.py b/whitebox_neutron_tempest_plugin/config.py index dca131d..1cbc207 100644 --- a/whitebox_neutron_tempest_plugin/config.py +++ b/whitebox_neutron_tempest_plugin/config.py @@ -119,6 +119,9 @@ WhiteboxNeutronPluginOptions = [ cfg.StrOpt('ext_bridge', default='br-ex', help="OpenvSwitch bridge dedicated for external network."), + cfg.StrOpt('node_integration_bridge', + default='br-int', + help="OpenvSwitch bridge dedicated for OVN's use."), cfg.IntOpt('ovn_max_controller_gw_ports_per_router', default=1, help='The number of network nodes used ' diff --git a/whitebox_neutron_tempest_plugin/tests/scenario/test_mtu.py b/whitebox_neutron_tempest_plugin/tests/scenario/test_mtu.py new file mode 100644 index 0000000..fb7b07b --- /dev/null +++ b/whitebox_neutron_tempest_plugin/tests/scenario/test_mtu.py @@ -0,0 +1,369 @@ +# Copyright 2024 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 time + +from neutron_lib import constants as lib_constants +from neutron_tempest_plugin.common import ssh +from neutron_tempest_plugin import config +from oslo_log import log +from tempest.lib import decorators + +from whitebox_neutron_tempest_plugin.common import constants +from whitebox_neutron_tempest_plugin.common import tcpdump_capture as capture +from whitebox_neutron_tempest_plugin.common import utils +from whitebox_neutron_tempest_plugin.tests.scenario import base + +CONF = config.CONF +WB_CONF = config.CONF.whitebox_neutron_plugin_options +LOG = log.getLogger(__name__) + + +class GatewayMtuTest(base.TrafficFlowTest): + credentials = ['primary', 'admin'] + + @classmethod + def skip_checks(cls): + super(GatewayMtuTest, cls).skip_checks() + if not CONF.network.public_network_id: + raise cls.skipException( + "The public_network_id option must be specified. ") + + @classmethod + def resource_setup(cls): + super(GatewayMtuTest, 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 + + if (not cls.flavor_ref) or (not cls.image_ref): + raise cls.skipException( + 'No advanced image/flavor available for these tests') + + cls.keypair = cls.create_keypair() + cls.secgroup = cls.create_security_group() + cls.create_loginable_secgroup_rule(secgroup_id=cls.secgroup['id']) + cls.create_pingable_secgroup_rule(secgroup_id=cls.secgroup['id']) + for protocol in ['udp', 'tcp']: + cls.os_primary.network_client.create_security_group_rule( + security_group_id=cls.secgroup['id'], + protocol=protocol, + direction=lib_constants.INGRESS_DIRECTION, + port_range_min=constants.NCAT_PORT, + port_range_max=constants.NCAT_PORT) + + def _verify_capture_for_icmp_unreacheable( + self, protocol, payload_size, src_client, dst_client, + must_contain=False): + """Check that ICMP 'fragmentation needed' message is sent when needed + + :param protocol(str): Allowed values 'udp', 'tcp'. + :param payload_size(int): Size in bytes. + :param src_client(Client): SSH client to the origin of the traffic. + :param dst_client(Client): SSH client to the destination host. + :param must_contain(bool): Specifies if we expect to get ICMP + 'fragmentation needed' message in the traffic capture. + :return: MTU size of next hop or None if the capture must not + contain ICMP message. + """ + udp = '-u' if protocol == 'udp' else '' + utils.create_payload_file(src_client, payload_size) + interface = utils.get_default_interface(src_client) + filters = 'icmp[icmptype] == 3 and icmp[icmpcode] == 4' + + src_server_capture = capture.TcpdumpCapture( + src_client, interface, filters) + self.useFixture(src_server_capture) + time.sleep(10) + + if not must_contain: + output_file = utils.run_ncat_server(dst_client, udp) + utils.run_ncat_client(src_client, dst_client.host, udp, payload_size) + time.sleep(5) + src_server_capture.stop() + + if must_contain: + self.assertFalse( + src_server_capture.is_empty(), + "No ICMP 'fragmentation needed' message") + return src_server_capture.get_next_hop_mtu() + else: + self.assertTrue( + src_server_capture.is_empty(), + "Unexpected ICMP 'fragmentation needed' message found") + output_file_size = int(dst_client.exec_command( + 'stat -c "%s" ' + output_file).rstrip()) + self.assertEqual( + output_file_size, payload_size, + "Delivered data has size {} bytes while expected {}.".format( + output_file_size, payload_size)) + + def _validate_environment_config(self): + msg = "ovn_emit_need_to_frag is not set to 'true' in config file" + for node in self.nodes: + result = node['client'].exec_command( + "sudo ovs-appctl -t ovs-vswitchd dpif/show-dp-features {} | " + "grep 'Check pkt'".format( + WB_CONF.node_integration_bridge)) + if 'Yes' not in result: + raise self.skipException( + "Path MTU discovery is not supported") + if WB_CONF.openstack_type == 'devstack': + if node['is_controller'] is False: + continue + else: + self.check_service_setting( + node, service='', + config_files=[WB_CONF.ml2_plugin_config], + section='ovn', param='ovn_emit_need_to_frag', + msg=msg) + if WB_CONF.openstack_type == 'podified': + config_files = self.get_configs_of_service() + self.check_service_setting( + {'client': self.proxy_host_client}, + config_files=config_files, section='ovn', + param='ovn_emit_need_to_frag', msg=msg) + + def setup(self): + self._validate_environment_config() + + self.network = self.create_network() + subnet = self.create_subnet(self.network) + self.router = self.create_router_by_client() + self.create_router_interface(self.router['id'], subnet['id']) + + self.external_network = self.client.show_network( + CONF.network.public_network_id)['network'] + ext_vm = self._create_server( + network=self.external_network, + create_floating_ip=False) + ext_vm_ssh_client = ssh.Client( + ext_vm['port']['fixed_ips'][0]['ip_address'], self.username, + pkey=self.keypair['private_key']) + self.local_client = ext_vm_ssh_client + + # We'll use the default self.server as a proxy for no-FIP scenario + self.server = self._create_server() + server_ssh_client = ssh.Client( + self.server['fip']['floating_ip_address'], + self.username, pkey=self.keypair['private_key']) + self.test_server = self._create_server(create_floating_ip=False) + test_server_ip = self.test_server['port']['fixed_ips'][0]['ip_address'] + self.test_server_client = ssh.Client( + test_server_ip, self.username, + pkey=self.keypair['private_key'], proxy_client=server_ssh_client) + self.validate_current_mtus() + + def validate_current_mtus(self): + self.internal_mtu = self.network['mtu'] + self.external_mtu = self.external_network['mtu'] + if int(self.external_mtu) > int(self.internal_mtu): + raise self.skipException( + 'Internal network MTU is smaller than external.') + + def validate_next_hop_mtu(self, starting_payload_size): + next_hop_mtu = self._verify_capture_for_icmp_unreacheable( + protocol='udp', + must_contain=True, + src_client=self.test_server_client, + dst_client=self.local_client, + payload_size=starting_payload_size) + payload_size = (int(next_hop_mtu) - constants.IP_HEADER_LENGTH - + constants.UDP_HEADER_LENGTH) + self.assertLess( + payload_size, starting_payload_size, + "Received next hop MTU %s is bigger than expected" % next_hop_mtu) + self._verify_capture_for_icmp_unreacheable( + protocol='udp', + must_contain=False, + src_client=self.test_server_client, + dst_client=self.local_client, + payload_size=payload_size) + + def check_pmtud_basic(self): + self.setup() + maximal_payload_for_internal_net = (self.internal_mtu - + constants.IP_HEADER_LENGTH - + constants.UDP_HEADER_LENGTH) + self.validate_next_hop_mtu( + starting_payload_size=maximal_payload_for_internal_net) + self.create_floatingip(port=self.test_server['port']) + self.validate_next_hop_mtu( + starting_payload_size=maximal_payload_for_internal_net) + + +class GatewayMtuTestIcmp(GatewayMtuTest): + + def make_sure_routing_cache_is_clear(self, ssh_client, dst): + utils.flush_routing_cache(ssh_client) + result = ssh_client.exec_command("ip route get %s" % dst) + if 'mtu' in result: + raise self.skipException( + 'Routing cache already contains mtu records') + + def validate_next_hop_mtu(self, starting_payload_size): + next_hop_mtu = self._verify_capture_for_icmp_unreacheable( + must_contain=True, + src_client=self.test_server_client, + dst_client=self.local_client, + payload_size=starting_payload_size) + payload_size = (int(next_hop_mtu) - constants.IP_HEADER_LENGTH - + constants.UDP_HEADER_LENGTH) + self.assertLess( + payload_size, starting_payload_size, + "Received next hop MTU %s is bigger than expected" % next_hop_mtu) + self._verify_capture_for_icmp_unreacheable( + must_contain=False, + src_client=self.test_server_client, + dst_client=self.local_client, + payload_size=starting_payload_size) + + def _verify_capture_for_icmp_unreacheable( + self, payload_size, src_client, dst_client, must_contain=False): + """Check that ICMP 'fragmentation needed' message is sent when needed + + :param payload_size(int): Size in bytes. + :param src_client(Client): SSH client to the origin of the traffic. + :param dst_client(Client): SSH client to the destination + :param must_contain(bool): Specifies if we expect to get ICMP + 'fragmentation needed' message in the traffic capture. + :return: MTU size of next hop or None if the capture must not + contain ICMP message. + """ + interface = utils.get_default_interface(src_client) + filters = 'icmp[icmptype] == 3 and icmp[icmpcode] == 4' + should_succeed = not must_contain + if must_contain: + self.make_sure_routing_cache_is_clear(src_client, dst_client.host) + time.sleep(2) + ping_count = 1 + else: + ping_count = 10 + + src_server_capture = capture.TcpdumpCapture( + src_client, interface, filters) + self.useFixture(src_server_capture) + time.sleep(10) + self.check_remote_connectivity( + source=src_client, dest=dst_client.host, mtu=payload_size, + should_succeed=should_succeed, ping_count=ping_count, timeout=20, + forbid_packet_loss=True) + time.sleep(5) + src_server_capture.stop() + + if must_contain: + self.assertFalse( + src_server_capture.is_empty(), + "No ICMP 'fragmentation needed' message") + return src_server_capture.get_next_hop_mtu() + else: + self.assertTrue( + src_server_capture.is_empty(), + "Unexpected ICMP 'fragmentation needed' message found") + return None + + @decorators.idempotent_id('10d73cf0-7506-4899-864c-cebe43099be3') + def test_northbound_pmtud_icmp(self): + self.check_pmtud_basic() + + +class GatewayMtuTestOvn(GatewayMtuTest, base.BaseTempestTestCaseOvn): + credentials = ['primary', 'admin'] + + def _validate_ovn_db_mtu_settings(self): + router_port = self.os_admin.network_client.list_ports( + device_id=self.router['id'], + device_owner=lib_constants.DEVICE_OWNER_ROUTER_GW)['ports'][0] + ovn_gateway_mtu = self.get_router_port_gateway_mtu(router_port['id']) + self.assertEqual( + self.external_mtu, ovn_gateway_mtu, + "MTU is not set properly in OVN DB") + + def _restore_external_network_mtu(self): + self.os_admin.network_client.update_network( + CONF.network.public_network_id, mtu=self.original_mtu) + + @decorators.idempotent_id('7f7470ff-31b4-4ad8-bfa7-82dcca174744') + def test_south_to_north_pmtud_udp_basic(self): + """Verify that south->north path MTU discovery is working for UDP + + Setup: + Environment with public network MTU smaller than tenant networks. + + Scenario: + 1. Create router connected to pre-existed external network. + It is expected that the external network MTU is lower than + internal network MTU (will be created in the next step) + 2. Create internal network, connect it to the router and spawn + 2 instances, one will be used as a proxy in order to be able + to access the test server even during no-FIP scenario. + 3. Launch traffic capture on the test server and send a UDP + packet matching the internal network MTU to the host + connected to the external network (local address from + external network is used). + 4. Validate that the router returned ICMP 'fragmentation + needed' message and send a new UDP packet matching MTU + in this message. + 5. Validate that the smaller file was delivered to the external + address and the router did not send any ICMP message. + 6. Add a FIP to the test server and repeat steps 3-5. + """ + self.check_pmtud_basic() + + @decorators.idempotent_id('cab28be4-7d11-485b-b99e-ea511fe2a675') + def test_south_to_north_pmtud_udp_change_mtu(self): + """Verify that south->north path MTU discovery is working for UDP + + Setup: + Environment with public network MTU smaller than tenant networks. + + Scenario: + 1. Create router connected to pre-existed external network. + It is expected that the external network MTU is lower than + internal network MTU (will be created in the next step) + 2. Create internal network, connect it to the router and spawn + 2 instances, one will be used as a proxy in order to be able + to access the test server even during no-FIP scenario. + 3. Verify that OVN DB MTU entries are configured correctly. + 4. Change MTU of the external network and verify that OVN DB + MTU settings upated accordingly. + 5. Verify that south->north path MTU discovery is working for UDP + using new the MTU (see details in test_south_to_north_pmtud_udp + description, steps 3-6) + 6. After the test the original MTU of the external network is + restored. + """ + self.setup() + self._validate_ovn_db_mtu_settings() + self.original_mtu = self.external_network['mtu'] + self.addCleanup(self._restore_external_network_mtu) + new_mtu = int(self.external_mtu) - 30 + self.external_network = self.os_admin.network_client.update_network( + CONF.network.public_network_id, mtu=new_mtu)['network'] + self.validate_current_mtus() + self._validate_ovn_db_mtu_settings() + minimal_payload_causing_fragmentation = ( + self.external_mtu - constants.ETHERNET_HEADER_LENGTH + 1) + self.validate_next_hop_mtu( + starting_payload_size=minimal_payload_causing_fragmentation) + self.create_floatingip(port=self.test_server['port']) + self.validate_next_hop_mtu( + starting_payload_size=minimal_payload_causing_fragmentation) diff --git a/zuul.d/master_jobs.yaml b/zuul.d/master_jobs.yaml index 854ac6a..1e39221 100644 --- a/zuul.d/master_jobs.yaml +++ b/zuul.d/master_jobs.yaml @@ -319,6 +319,8 @@ /$NEUTRON_CORE_PLUGIN_CONF: ml2: type_drivers: local,flat,vlan,geneve + ovn: + ovn_emit_need_to_frag: true test-config: $TEMPEST_CONFIG: network-feature-enabled: