Add first VLAN Transparency tests

These tests are only executed if the vlan-transparent extension is installed
Advanced images are required because VLANs need to be configured on
the servers, which is not possible with cirros
Connectivity between servers via VLAN interface is verified

Functions add_route, delete_route and delete_address are added to class
IPCommand because they are needed for some VLAN Transparency downstream tests

Change-Id: I448203ead31f17a51f756667f6b3fc8e70a77ed2
diff --git a/neutron_tempest_plugin/common/ip.py b/neutron_tempest_plugin/common/ip.py
index d981770..7b172b0 100644
--- a/neutron_tempest_plugin/common/ip.py
+++ b/neutron_tempest_plugin/common/ip.py
@@ -57,6 +57,20 @@
         return shell.execute(command_line, ssh_client=self.ssh_client,
                              timeout=self.timeout).stdout
 
+    def configure_vlan(self, addresses, port, vlan_tag, subport_ips):
+        port_device = get_port_device_name(addresses=addresses, port=port)
+        subport_device = '{!s}.{!s}'.format(port_device, vlan_tag)
+        LOG.debug('Configuring VLAN subport interface %r on top of interface '
+                  '%r with IPs: %s', subport_device, port_device,
+                  ', '.join(subport_ips))
+
+        self.add_link(link=port_device, name=subport_device, link_type='vlan',
+                      segmentation_id=vlan_tag)
+        self.set_link(device=subport_device, state='up')
+        for subport_ip in subport_ips:
+            self.add_address(address=subport_ip, device=subport_device)
+        return subport_device
+
     def configure_vlan_subport(self, port, subport, vlan_tag, subnets):
         addresses = self.list_addresses()
         try:
@@ -77,18 +91,19 @@
                 "Unable to get IP address and subnet prefix lengths for "
                 "subport")
 
-        port_device = get_port_device_name(addresses=addresses, port=port)
-        subport_device = '{!s}.{!s}'.format(port_device, vlan_tag)
-        LOG.debug('Configuring VLAN subport interface %r on top of interface '
-                  '%r with IPs: %s', subport_device, port_device,
-                  ', '.join(subport_ips))
+        return self.configure_vlan(addresses, port, vlan_tag, subport_ips)
 
-        self.add_link(link=port_device, name=subport_device, link_type='vlan',
-                      segmentation_id=vlan_tag)
-        self.set_link(device=subport_device, state='up')
-        for subport_ip in subport_ips:
-            self.add_address(address=subport_ip, device=subport_device)
-        return subport_device
+    def configure_vlan_transparent(self, port, vlan_tag, ip_addresses):
+        addresses = self.list_addresses()
+        try:
+            subport_device = get_vlan_device_name(addresses, ip_addresses)
+        except ValueError:
+            pass
+        else:
+            LOG.debug('Interface %r already configured.', subport_device)
+            return subport_device
+
+        return self.configure_vlan(addresses, port, vlan_tag, ip_addresses)
 
     def list_namespaces(self):
         namespaces_output = self.execute("netns")
@@ -128,6 +143,23 @@
         # ip addr add 192.168.1.1/24 dev em1
         return self.execute('address', 'add', address, 'dev', device)
 
+    def delete_address(self, address, device):
+        # ip addr del 192.168.1.1/24 dev em1
+        return self.execute('address', 'del', address, 'dev', device)
+
+    def add_route(self, address, device, gateway=None):
+        if gateway:
+            # ip route add 192.168.1.0/24 via 192.168.22.1 dev em1
+            return self.execute(
+                'route', 'add', address, 'via', gateway, 'dev', device)
+        else:
+            # ip route add 192.168.1.0/24 dev em1
+            return self.execute('route', 'add', address, 'dev', device)
+
+    def delete_route(self, address, device):
+        # ip route del 192.168.1.0/24 dev em1
+        return self.execute('route', 'del', address, 'dev', device)
+
     def list_routes(self, *args):
         output = self.execute('route', 'show', *args)
         return list(parse_routes(output))
@@ -312,6 +344,15 @@
     raise ValueError(msg)
 
 
+def get_vlan_device_name(addresses, ip_addresses):
+    for address in list_ip_addresses(addresses=addresses,
+            ip_addresses=ip_addresses):
+        return address.device.name
+
+    msg = "Fixed IPs {0!r} not found on server.".format(' '.join(ip_addresses))
+    raise ValueError(msg)
+
+
 def _get_ip_address_prefix_len_pairs(port, subnets):
     subnets = {subnet['id']: subnet for subnet in subnets}
     for fixed_ip in port['fixed_ips']:
diff --git a/neutron_tempest_plugin/scenario/test_vlan_transparency.py b/neutron_tempest_plugin/scenario/test_vlan_transparency.py
new file mode 100644
index 0000000..d9a529c
--- /dev/null
+++ b/neutron_tempest_plugin/scenario/test_vlan_transparency.py
@@ -0,0 +1,186 @@
+# Copyright (c) 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.
+
+from oslo_log import log as logging
+from tempest.lib.common.utils import data_utils
+from tempest.lib import decorators
+
+from neutron_tempest_plugin.common import ip
+from neutron_tempest_plugin.common import ssh
+from neutron_tempest_plugin import config
+from neutron_tempest_plugin.scenario import base
+
+
+LOG = logging.getLogger(__name__)
+CONF = config.CONF
+MIN_VLAN_ID = 1
+MAX_VLAN_ID = 4094
+
+
+class VlanTransparencyTest(base.BaseTempestTestCase):
+    credentials = ['primary', 'admin']
+    force_tenant_isolation = False
+
+    required_extensions = ['vlan-transparent', 'allowed-address-pairs']
+
+    @classmethod
+    def resource_setup(cls):
+        super(VlanTransparencyTest, cls).resource_setup()
+        # setup basic topology for servers we can log into
+        cls.rand_name = data_utils.rand_name(
+            cls.__name__.rsplit('.', 1)[-1])
+        cls.network = cls.create_network(name=cls.rand_name,
+                                         vlan_transparent=True)
+        cls.subnet = cls.create_subnet(network=cls.network,
+                                       name=cls.rand_name)
+        cls.router = cls.create_router_by_client()
+        cls.create_router_interface(cls.router['id'], cls.subnet['id'])
+        cls.keypair = cls.create_keypair(name=cls.rand_name)
+        cls.vm_ports = []
+        cls.security_group = cls.create_security_group(name=cls.rand_name)
+        cls.create_loginable_secgroup_rule(cls.security_group['id'])
+
+        if CONF.neutron_plugin_options.default_image_is_advanced:
+            cls.flavor_ref = CONF.compute.flavor_ref
+            cls.image_ref = CONF.compute.image_ref
+        else:
+            cls.flavor_ref = \
+                CONF.neutron_plugin_options.advanced_image_flavor_ref
+            cls.image_ref = CONF.neutron_plugin_options.advanced_image_ref
+
+    @classmethod
+    def skip_checks(cls):
+        super(VlanTransparencyTest, cls).skip_checks()
+        if not (CONF.neutron_plugin_options.advanced_image_ref or
+                CONF.neutron_plugin_options.default_image_is_advanced):
+            raise cls.skipException(
+                'Advanced image is required to run these tests.')
+
+    def _create_port_and_server(self, index,
+                                port_security=True,
+                                allowed_address_pairs=None):
+        server_name = 'server-%s-%d' % (self.rand_name, index)
+        port_name = 'port-%s-%d' % (self.rand_name, index)
+        if port_security:
+            sec_groups = [self.security_group['id']]
+        else:
+            sec_groups = None
+        self.vm_ports.append(
+            self.create_port(network=self.network, name=port_name,
+                             security_groups=sec_groups,
+                             port_security_enabled=port_security,
+                             allowed_address_pairs=allowed_address_pairs))
+        return self.create_server(flavor_ref=self.flavor_ref,
+                                  image_ref=self.image_ref,
+                                  key_name=self.keypair['name'],
+                                  networks=[{'port': self.vm_ports[-1]['id']}],
+                                  name=server_name)['server']
+
+    def _configure_vlan_transparent(self, port, ssh_client,
+                                    vlan_tag, vlan_ip):
+        ip_command = ip.IPCommand(ssh_client=ssh_client)
+        addresses = ip_command.list_addresses(port=port)
+        port_iface = ip.get_port_device_name(addresses, port)
+        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.")
+
+    def _create_ssh_client(self, floating_ip):
+        if CONF.neutron_plugin_options.default_image_is_advanced:
+            username = CONF.validation.image_ssh_user
+        else:
+            username = CONF.neutron_plugin_options.advanced_image_ssh_user
+        return ssh.Client(host=floating_ip['floating_ip_address'],
+                          username=username,
+                          pkey=self.keypair['private_key'])
+
+    def _test_basic_vlan_transparency_connectivity(
+            self, port_security=True, use_allowed_address_pairs=False):
+        vlan_tag = data_utils.rand_int_id(start=MIN_VLAN_ID, end=MAX_VLAN_ID)
+        vlan_ipmask_template = '192.168.%d.{ip_last_byte}/24' % (vlan_tag %
+                                                                 256)
+        vms = []
+        vlan_ipmasks = []
+        floating_ips = []
+        ssh_clients = []
+
+        for i in range(2):
+            vlan_ipmasks.append(vlan_ipmask_template.format(
+                ip_last_byte=(i + 1) * 10))
+            if use_allowed_address_pairs:
+                allowed_address_pairs = [{'ip_address': vlan_ipmasks[i]}]
+            else:
+                allowed_address_pairs = None
+            vms.append(self._create_port_and_server(
+                index=i,
+                port_security=port_security,
+                allowed_address_pairs=allowed_address_pairs))
+            floating_ips.append(self.create_floatingip(port=self.vm_ports[-1]))
+            ssh_clients.append(
+                self._create_ssh_client(floating_ip=floating_ips[i]))
+
+            self.check_connectivity(
+                host=floating_ips[i]['floating_ip_address'],
+                ssh_client=ssh_clients[i])
+            self._configure_vlan_transparent(port=self.vm_ports[-1],
+                                             ssh_client=ssh_clients[i],
+                                             vlan_tag=vlan_tag,
+                                             vlan_ip=vlan_ipmasks[i])
+
+        if port_security:
+            # Ping from vm0 to vm1 via VLAN interface should fail because
+            # we haven't allowed ICMP
+            self.check_remote_connectivity(
+                ssh_clients[0],
+                vlan_ipmasks[1].split('/')[0],
+                servers=vms,
+                should_succeed=False)
+
+            # allow intra-security-group traffic
+            sg_rule = self.create_pingable_secgroup_rule(
+                self.security_group['id'])
+            self.addCleanup(
+                    self.os_primary.network_client.delete_security_group_rule,
+                    sg_rule['id'])
+
+        # Ping from vm0 to vm1 via VLAN interface should pass because
+        # either port security is disabled or the ICMP sec group rule has been
+        # added
+        self.check_remote_connectivity(
+            ssh_clients[0],
+            vlan_ipmasks[1].split('/')[0],
+            servers=vms)
+        # Ping from vm1 to vm0 and check untagged packets are not dropped
+        self.check_remote_connectivity(
+            ssh_clients[1],
+            self.vm_ports[-2]['fixed_ips'][0]['ip_address'],
+            servers=vms)
+
+    @decorators.idempotent_id('a2694e3a-6d4d-4a23-9fcc-c3ed3ef37b16')
+    def test_vlan_transparent_port_sec_disabled(self):
+        self._test_basic_vlan_transparency_connectivity(
+            port_security=False, use_allowed_address_pairs=False)
+
+    @decorators.idempotent_id('2dd03b4f-9c20-4cda-8c6a-40fa453ec69a')
+    def test_vlan_transparent_allowed_address_pairs(self):
+        self._test_basic_vlan_transparency_connectivity(
+            port_security=True, use_allowed_address_pairs=True)
diff --git a/zuul.d/master_jobs.yaml b/zuul.d/master_jobs.yaml
index 5325f67..ee7c6e3 100644
--- a/zuul.d/master_jobs.yaml
+++ b/zuul.d/master_jobs.yaml
@@ -173,15 +173,21 @@
     pre-run: playbooks/linuxbridge-scenario-pre-run.yaml
     vars:
       network_api_extensions: *api_extensions
+      network_api_extensions_linuxbridge:
+        - vlan-transparent
       network_available_features: *available_features
+      # TODO(eolivare): remove VLAN Transparency tests from blacklist
+      # when bug https://bugs.launchpad.net/neutron/+bug/1907548 will be fixed
+      tempest_black_regex: "(^neutron_tempest_plugin.scenario.test_vlan_transparency.VlanTransparencyTest)"
       devstack_localrc:
         Q_AGENT: linuxbridge
-        NETWORK_API_EXTENSIONS: "{{ network_api_extensions | join(',') }}"
+        NETWORK_API_EXTENSIONS: "{{ (network_api_extensions + network_api_extensions_linuxbridge) | join(',') }}"
       devstack_local_conf:
         post-config:
           $NEUTRON_CONF:
             DEFAULT:
               enable_dvr: false
+              vlan_transparent: true
             AGENT:
               debug_iptables_rules: true
           # NOTE(slaweq): We can get rid of this hardcoded absolute path when
@@ -190,6 +196,7 @@
           /$NEUTRON_CORE_PLUGIN_CONF:
             ml2:
               type_drivers: flat,vlan,local,vxlan
+              mechanism_drivers: linuxbridge
         test-config:
           $TEMPEST_CONFIG:
             network-feature-enabled:
@@ -204,6 +211,8 @@
     timeout: 10000
     vars:
       network_api_extensions: *api_extensions
+      network_api_extensions_ovn:
+        - vlan-transparent
       # TODO(haleyb): Remove IPv6Test from blacklist when
       # https://bugs.launchpad.net/neutron/+bug/1881558 is fixed.
       # TODO(slaweq): Remove test_trunk_subport_lifecycle test from the
@@ -217,7 +226,7 @@
           (^neutron_tempest_plugin.scenario.test_mtu.NetworkWritableMtuTest)"
       devstack_localrc:
         Q_AGENT: ovn
-        NETWORK_API_EXTENSIONS: "{{ network_api_extensions | join(',') }}"
+        NETWORK_API_EXTENSIONS: "{{ (network_api_extensions + network_api_extensions_ovn) | join(',') }}"
         Q_ML2_PLUGIN_MECHANISM_DRIVERS: ovn,logger
         Q_ML2_PLUGIN_TYPE_DRIVERS: local,flat,vlan,geneve
         Q_ML2_TENANT_NETWORK_TYPE: geneve
@@ -228,6 +237,11 @@
         OVN_DBS_LOG_LEVEL: dbg
         ENABLE_TLS: True
         OVN_IGMP_SNOOPING_ENABLE: True
+        # TODO(eolivare): Remove OVN_BUILD_FROM_SOURCE once vlan-transparency
+        # is included in an ovn released version
+        OVN_BUILD_FROM_SOURCE: True
+        OVN_BRANCH: "v20.12.0"
+        OVS_BRANCH: "branch-2.15"
       devstack_services:
         br-ex-tcpdump: true
         br-int-flows: true
@@ -259,6 +273,7 @@
           $NEUTRON_CONF:
             DEFAULT:
               enable_dvr: false
+              vlan_transparent: true
           /$NEUTRON_CORE_PLUGIN_CONF:
             ml2:
               type_drivers: local,flat,vlan,geneve
diff --git a/zuul.d/ussuri_jobs.yaml b/zuul.d/ussuri_jobs.yaml
index b71a460..9cc0621 100644
--- a/zuul.d/ussuri_jobs.yaml
+++ b/zuul.d/ussuri_jobs.yaml
@@ -163,6 +163,7 @@
         OVN_BUILD_MODULES: True
         # TODO(skaplons): v2.13.1 is incompatible with kernel 4.15.0-118, sticking to commit hash until new v2.13 tag is created
         OVS_BRANCH: 0047ca3a0290f1ef954f2c76b31477cf4b9755f5
+        OVN_BRANCH: "v20.03.0"
 
 - job:
     name: neutron-tempest-plugin-dvr-multinode-scenario-ussuri