Merge "Revert "Make neutron-tempest-plugin-sfc job non-voting temporary""
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000..2ff8c11
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,33 @@
+---
+default_language_version:
+  # force all unspecified python hooks to run python3
+  python: python3
+repos:
+  - repo: https://github.com/pre-commit/pre-commit-hooks
+    rev: v4.5.0
+    hooks:
+      - id: trailing-whitespace
+      - id: mixed-line-ending
+        args: ['--fix', 'lf']
+        exclude: '.*\.(svg)$'
+      - id: check-byte-order-marker
+      - id: check-executables-have-shebangs
+      - id: check-merge-conflict
+      - id: debug-statements
+      - id: check-yaml
+        files: .*\.(yaml|yml)$
+  - repo: https://github.com/lucas-c/pre-commit-hooks
+    rev: v1.5.4
+    hooks:
+      - id: remove-tabs
+        exclude: '.*\.(svg)$'
+  - repo: local
+    hooks:
+      - id: flake8
+        name: flake8
+        additional_dependencies:
+          - hacking>=3.2.0,<3.3.0
+        language: python
+        entry: flake8
+        files: '^.*\.py$'
+        exclude: '^(doc|releasenotes|tools)/.*$'
diff --git a/doc/source/conf.py b/doc/source/conf.py
index 88b9718..a5d87bb 100644
--- a/doc/source/conf.py
+++ b/doc/source/conf.py
@@ -16,7 +16,10 @@
 import sys
 
 def autodoc_skip_member_handler(app, what, name, obj, skip, options):
-    return skip or (what == "class" and not name.startswith("test"))
+    return skip or (
+        (what == "class" and not name.startswith("test")) or
+        # NOTE(fnordahl): Sphinx does not like the ASCII art in the docstring.
+        (what == 'module' and name == 'NetworkMultipleGWTest'))
 
 def setup(app):
     app.connect('autodoc-skip-member', autodoc_skip_member_handler)
diff --git a/neutron_tempest_plugin/api/base.py b/neutron_tempest_plugin/api/base.py
index 7f056d1..e260313 100644
--- a/neutron_tempest_plugin/api/base.py
+++ b/neutron_tempest_plugin/api/base.py
@@ -875,9 +875,10 @@
                                            association['fixed_port_id'])
 
     @classmethod
-    def create_router_interface(cls, router_id, subnet_id):
+    def create_router_interface(cls, router_id, subnet_id, client=None):
         """Wrapper utility that returns a router interface."""
-        interface = cls.client.add_router_interface_with_subnet_id(
+        client = client or cls.client
+        interface = client.add_router_interface_with_subnet_id(
             router_id, subnet_id)
         return interface
 
@@ -1220,6 +1221,16 @@
         client = client or ndp_proxy.get('client') or cls.client
         client.delete_ndp_proxy(ndp_proxy['id'])
 
+    @classmethod
+    def get_loaded_network_extensions(cls):
+        """Return the network service loaded extensions
+
+        :return: list of strings with the alias of the network service loaded
+                 extensions.
+        """
+        body = cls.client.list_extensions()
+        return [net_ext['alias'] for net_ext in body['extensions']]
+
 
 class BaseAdminNetworkTest(BaseNetworkTest):
 
@@ -1350,13 +1361,14 @@
 
     @classmethod
     def create_provider_network(cls, physnet_name, start_segmentation_id,
-                                max_attempts=30):
+                                max_attempts=30, external=False):
         segmentation_id = start_segmentation_id
         for attempts in range(max_attempts):
             try:
                 return cls.create_network(
                     name=data_utils.rand_name('test_net'),
-                    shared=True,
+                    shared=not external,
+                    external=external,
                     provider_network_type='vlan',
                     provider_physical_network=physnet_name,
                     provider_segmentation_id=segmentation_id)
@@ -1454,6 +1466,10 @@
     def _extract_resources(cls, body):
         return body[cls.plural_name]
 
+    @classmethod
+    def _test_resources(cls, resources):
+        return [res for res in resources if res["name"] in cls.resource_names]
+
     def _test_list_sorts(self, direction):
         sort_args = {
             'sort_dir': direction,
@@ -1505,11 +1521,12 @@
             'sort_key': self.field
         }
         body = self.list_method(**sort_args)
-        expected_resources = self._extract_resources(body)
+        total_resources = self._extract_resources(body)
+        expected_resources = self._test_resources(total_resources)
         self.assertNotEmpty(expected_resources)
 
         resources = lister(
-            len(expected_resources), sort_args
+            len(total_resources), sort_args
         )
 
         # finally, compare that the list retrieved in one go is identical to
@@ -1529,7 +1546,7 @@
             resources_ = self._extract_resources(body)
             self.assertEqual(1, len(resources_))
             resources.extend(resources_)
-        return resources
+        return self._test_resources(resources)
 
     @_require_pagination
     @_require_sorting
@@ -1553,7 +1570,7 @@
             )
             resources_ = self._extract_resources(body)
             self.assertEqual(1, len(resources_))
-            resources.extend(resources_)
+            resources.extend(self._test_resources(resources_))
 
         # The last element is empty and does not contain 'next' link
         uri = self.get_bare_url(prev_links['next'])
@@ -1571,7 +1588,7 @@
             )
             resources_ = self._extract_resources(body)
             self.assertEqual(1, len(resources_))
-            resources2.extend(resources_)
+            resources2.extend(self._test_resources(resources_))
 
         self.assertSameOrder(resources, reversed(resources2))
 
@@ -1591,14 +1608,15 @@
             'sort_key': self.field,
         }
         body = self.list_method(**pagination_args)
-        expected_resources = self._extract_resources(body)
+        total_resources = self._extract_resources(body)
+        expected_resources = self._test_resources(total_resources)
 
         page_size = 2
         pagination_args['limit'] = page_size
 
         prev_links = {}
         resources = []
-        num_resources = len(expected_resources)
+        num_resources = len(total_resources)
         niterations = int(math.ceil(float(num_resources) / page_size))
         for i in range(niterations):
             if prev_links:
@@ -1610,7 +1628,7 @@
             prev_links, body = self.list_client.get_uri_with_links(
                 self.plural_name, uri
             )
-            resources_ = self._extract_resources(body)
+            resources_ = self._test_resources(self._extract_resources(body))
             self.assertGreaterEqual(page_size, len(resources_))
             resources.extend(reversed(resources_))
 
diff --git a/neutron_tempest_plugin/api/test_auto_allocated_topology.py b/neutron_tempest_plugin/api/test_auto_allocated_topology.py
index 4d70bfc..2177402 100644
--- a/neutron_tempest_plugin/api/test_auto_allocated_topology.py
+++ b/neutron_tempest_plugin/api/test_auto_allocated_topology.py
@@ -79,6 +79,14 @@
         body = client.list_networks(name='auto_allocated_network')
         self.networks.extend(body['networks'])
 
+    @decorators.idempotent_id('10e4ecef-4309-4bda-9c4d-192e1b5a5831')
+    def test_check_requirements_for_auto_allocate_net_topology(self):
+        body = self.client.validate_auto_allocated_topology_requirements()
+        topology = body['auto_allocated_topology']
+        self.assertIn('dry-run', topology['id'])
+        self.assertIn('tenant_id', topology)
+        self.assertIn('project_id', topology)
+
     @decorators.idempotent_id('64bc0b02-cee4-11e5-9f3c-080027605a2b')
     def test_get_allocated_net_topology_as_tenant(self):
         resources_before = self._count_topology_resources()
diff --git a/neutron_tempest_plugin/api/test_router_interface_fip.py b/neutron_tempest_plugin/api/test_router_interface_fip.py
index 5d8ab67..dcaa17c 100644
--- a/neutron_tempest_plugin/api/test_router_interface_fip.py
+++ b/neutron_tempest_plugin/api/test_router_interface_fip.py
@@ -83,7 +83,7 @@
                                fip2['revision_number'])
         # NOTE(yamamoto): The status can be updated asynchronously.
         fip2_shown = self.client.show_floatingip(fip2['id'])['floatingip']
-        if 'revision_number' in fip2:
+        if 'revision_number' in fip2_shown:
             self.assertGreaterEqual(fip2_shown['revision_number'],
                                     fip2_updated['revision_number'])
         fip2_shown.pop('status')
diff --git a/neutron_tempest_plugin/api/test_routers.py b/neutron_tempest_plugin/api/test_routers.py
index 66cea78..7758b1a 100644
--- a/neutron_tempest_plugin/api/test_routers.py
+++ b/neutron_tempest_plugin/api/test_routers.py
@@ -329,13 +329,10 @@
 class ExternalGWMultihomingRoutersTest(base_routers.BaseRouterTest):
 
     @classmethod
+    @tutils.requires_ext(extension="external-gateway-multihoming",
+                         service="network")
     def setUpClass(cls):
         super().setUpClass()
-        ext_alias = 'external-gateway-multihoming'
-        try:
-            cls.client.get_extension(ext_alias)
-        except lib_exc.NotFound:
-            raise cls.skipException(f'{ext_alias} extension not available.')
 
     @decorators.idempotent_id('33e9a156-a83f-435f-90ee-1a49dc9c350d')
     def test_create_router_enable_default_route_ecmp(self):
diff --git a/neutron_tempest_plugin/api/test_subnets.py b/neutron_tempest_plugin/api/test_subnets.py
index f7a38a4..23574ee 100644
--- a/neutron_tempest_plugin/api/test_subnets.py
+++ b/neutron_tempest_plugin/api/test_subnets.py
@@ -11,7 +11,6 @@
 #    under the License.
 
 import netaddr
-from tempest.common import utils as tutils
 from tempest.lib import decorators
 
 from neutron_tempest_plugin.api import base
@@ -25,8 +24,6 @@
 
     @classmethod
     def resource_setup(cls):
-        if tutils.is_extension_enabled('subnet-external-network', 'network'):
-            cls.list_kwargs['router:external'] = False
         super(SubnetsSearchCriteriaTest, cls).resource_setup()
         net = cls.create_network(network_name='subnet-search-test-net')
         for name in cls.resource_names:
@@ -70,6 +67,8 @@
 
     @decorators.idempotent_id('c0f9280b-9d81-4728-a967-6be22659d4c8')
     def test_list_validation_filters(self):
+        if 'subnet-external-network' in self.get_loaded_network_extensions():
+            self.list_kwargs['router:external'] = False
         self._test_list_validation_filters(self.list_kwargs)
         self._test_list_validation_filters({
             'unknown_filter': 'value'}, filter_is_valid=False)
diff --git a/neutron_tempest_plugin/scenario/base.py b/neutron_tempest_plugin/scenario/base.py
index 72139de..6149b06 100644
--- a/neutron_tempest_plugin/scenario/base.py
+++ b/neutron_tempest_plugin/scenario/base.py
@@ -286,6 +286,7 @@
 
     def setup_network_and_server(self, router=None, server_name=None,
                                  network=None, use_stateless_sg=False,
+                                 create_fip=True, router_client=None,
                                  **kwargs):
         """Create network resources and a server.
 
@@ -309,7 +310,8 @@
         self.security_groups.append(secgroup['security_group'])
         if not router:
             router = self.create_router_by_client(**kwargs)
-        self.create_router_interface(router['id'], self.subnet['id'])
+        self.create_router_interface(router['id'], self.subnet['id'],
+                                     client=router_client)
         self.keypair = self.create_keypair()
         self.create_loginable_secgroup_rule(
             secgroup_id=secgroup['security_group']['id'])
@@ -331,7 +333,9 @@
         self.port = self.client.list_ports(network_id=self.network['id'],
                                            device_id=self.server[
                                                'server']['id'])['ports'][0]
-        self.fip = self.create_floatingip(port=self.port)
+
+        if create_fip:
+            self.fip = self.create_floatingip(port=self.port)
 
     def check_connectivity(self, host, ssh_user=None, ssh_key=None,
                            servers=None, ssh_timeout=None, ssh_client=None):
@@ -696,3 +700,8 @@
         except exceptions.SSHScriptFailed:
             raise self.skipException(
                 "%s is not available on server %s" % (cmd, server['id']))
+
+
+class BaseAdminTempestTestCase(base_api.BaseAdminNetworkTest,
+                               BaseTempestTestCase):
+    pass
diff --git a/neutron_tempest_plugin/scenario/test_metadata.py b/neutron_tempest_plugin/scenario/test_metadata.py
index af6bd09..239f6bc 100644
--- a/neutron_tempest_plugin/scenario/test_metadata.py
+++ b/neutron_tempest_plugin/scenario/test_metadata.py
@@ -12,7 +12,10 @@
 # License for the specific language governing permissions and limitations
 # under the License.
 
+import base64
 import collections
+import textwrap
+import time
 
 from neutron_lib import constants as nlib_const
 from oslo_log import log as logging
@@ -32,6 +35,8 @@
 Server = collections.namedtuple(
     'Server', ['floating_ip', 'server', 'ssh_client'])
 
+QUERY_MSG = 'Queried the metadata service over IPv6'
+
 
 class MetadataTest(base.BaseTempestTestCase):
 
@@ -86,19 +91,49 @@
                                 security_groups=[self.security_group['id']],
                                 **params)
 
-    def _create_server(self, port, use_advanced_image=False, **params):
+    def _create_server(self, port=None, network_id=None,
+                       use_advanced_image=False, **params):
         if use_advanced_image:
             flavor_ref = CONF.neutron_plugin_options.advanced_image_flavor_ref
             image_ref = CONF.neutron_plugin_options.advanced_image_ref
         else:
             flavor_ref = CONF.compute.flavor_ref
             image_ref = CONF.compute.image_ref
+        if port:
+            networks = [{'port': port['id']}]
+        else:
+            networks = [{'uuid': network_id}]
         return self.create_server(flavor_ref=flavor_ref,
                                   image_ref=image_ref,
                                   key_name=self.keypair['name'],
-                                  networks=[{'port': port['id']}],
+                                  networks=networks,
                                   **params)['server']
 
+    def _get_metadata_query_script(self):
+        sheebang_line = '\n#!/bin/bash'
+        curl_cmd = '\ncurl http://[%(address)s' % {'address':
+                                                   nlib_const.METADATA_V6_IP}
+        ip_cmd = ("%$(ip -6 -br address show scope link up | head -1 | "
+                  "cut -d ' ' -f1)]/openstack/")
+        echo_cmd = '\necho %s' % QUERY_MSG
+        script = '%s%s%s%s' % (sheebang_line, curl_cmd, ip_cmd, echo_cmd)
+        script_clean = textwrap.dedent(script).lstrip().encode('utf8')
+        script_b64 = base64.b64encode(script_clean)
+        return {'user_data': script_b64}
+
+    def _wait_for_metadata_query_msg(self, vm):
+        timeout = 300
+        start_time = int(time.time())
+        while int(time.time()) - start_time < timeout:
+            console_output = self.os_primary.servers_client.get_console_output(
+                vm['id'])['output']
+            pos = console_output.find(QUERY_MSG)
+            if pos > -1:
+                return console_output, pos
+            time.sleep(30)
+        self.fail('Failed to find metadata query message in console log %s' %
+                  console_output)
+
     def _create_ssh_client(self, floating_ip, use_advanced_image=False):
         if use_advanced_image:
             username = CONF.neutron_plugin_options.advanced_image_ssh_user
@@ -153,3 +188,28 @@
         except exceptions.SSHExecCommandFailed:
             self._log_console_output()
             self._log_local_network_status()
+
+    @testtools.skipUnless(
+        CONF.neutron_plugin_options.advanced_image_ref or
+        CONF.neutron_plugin_options.default_image_is_advanced,
+        'Advanced image is required to run this test.')
+    @testtools.skipUnless(
+        CONF.neutron_plugin_options.firewall_driver == 'ovn',
+        "OVN driver is required to run this test - "
+        " while LP#2076916 is fixed")
+    @decorators.idempotent_id('7542892a-d132-471c-addb-172dcf888ff6')
+    def test_metadata_ipv6_only_network(self):
+        ipv6_network = self.create_network()
+        self.create_subnet(network=ipv6_network, ip_version=6,
+                           ipv6_ra_mode="slaac", ipv6_address_mode="slaac")
+        use_advanced_image = (
+            not CONF.neutron_plugin_options.default_image_is_advanced)
+        params = self._get_metadata_query_script()
+        params['config_drive'] = True
+        vm = self._create_server(
+            network_id=ipv6_network['id'],
+            use_advanced_image=use_advanced_image, **params)
+        self.wait_for_server_active(server=vm)
+        self.wait_for_guest_os_ready(vm)
+        console_output, pos = self._wait_for_metadata_query_msg(vm)
+        self.assertIn('latest', console_output[pos - 100:])
diff --git a/neutron_tempest_plugin/scenario/test_multiple_gws.py b/neutron_tempest_plugin/scenario/test_multiple_gws.py
new file mode 100644
index 0000000..686457d
--- /dev/null
+++ b/neutron_tempest_plugin/scenario/test_multiple_gws.py
@@ -0,0 +1,750 @@
+# Copyright 2023 Canonical
+# 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 json
+import os
+import subprocess
+import time
+import typing
+
+import netaddr
+import testtools
+
+from tempest.common import utils as tutils
+
+from neutron_tempest_plugin import config
+from neutron_tempest_plugin.scenario import base
+
+from neutron_lib import constants as const
+
+from oslo_log import log
+
+from os_ken.tests.integrated.common import docker_base as ctn_base
+
+from tempest.lib.common.utils import data_utils
+from tempest.lib import decorators
+from tempest.lib import exceptions as lib_exc
+
+CONF = config.CONF
+LOG = log.getLogger(__name__)
+
+
+class FRROCIImage(ctn_base.DockerImage):
+    def __init__(
+        self,
+        daemons: typing.Tuple[str],
+        baseimage: typing.Optional[str] = None,
+        use_existing: bool = False,
+    ):
+        super().__init__(baseimage=baseimage or 'ubuntu:22.04')
+        self.daemons = daemons
+        self.tagname = 'frr-' + '-'.join(daemons)
+        if use_existing and self.exist(self.tagname):
+            return
+
+        workdir = os.path.join(ctn_base.TEST_BASE_DIR, self.tagname)
+        pkgs = ' '.join(('telnet', 'tcpdump', 'frr'))
+        c = ctn_base.CmdBuffer()
+        c << f'FROM {self.baseimage}'
+        c << 'RUN apt-get update'
+        c << f'RUN apt-get install -qy --no-install-recommends {pkgs}'
+        c << 'RUN echo "#!/bin/sh" > /frr'
+        c << 'RUN echo mkdir -p /run/frr >> /frr'
+        c << 'RUN echo chmod 755 /run/frr >> /frr'
+        c << 'RUN echo chown frr:frr /run/frr >> /frr'
+        c << (
+            'RUN echo exec /usr/lib/frr/watchfrr '
+            f'-F traditional {" ".join(self.daemons)}>> /frr'
+        )
+        c << 'RUN chmod +x /frr'
+        c << 'CMD /frr'
+
+        self.cmd.sudo(f'rm -rf {workdir}')
+        self.cmd.execute(f'mkdir -p {workdir}')
+        self.cmd.execute(f"echo '{str(c)}' > {workdir}/Dockerfile")
+        self.build(self.tagname, workdir)
+
+
+class FRRContainer(ctn_base.Container):
+    class veth_info(typing.NamedTuple):
+        bridge_name: str
+        bridge_type: str
+        ctn_ifname: str
+        host_ifname: str
+
+    _veths: typing.List[veth_info]
+
+    class route(typing.NamedTuple):
+        dst: netaddr.IPNetwork
+        next_hop: netaddr.IPNetwork
+
+    _ctn_routes: typing.List[route]
+
+    def __init__(
+        self,
+        name: str,
+        image: FRROCIImage,
+    ):
+        self._veths = []
+        self._ctn_routes = []
+        super().__init__(name, image.tagname)
+
+    # XXX upstream to os-ken
+    def next_if_name(self) -> str:
+        name = 'eth{0}'.format(len(self.eths))
+        self.eths.append(name)
+        return name
+
+    # XXX upstream to os-ken
+    def run(self, network: typing.Optional[str] = None) -> int:
+        c = ctn_base.CmdBuffer(' ')
+        c << "docker run --privileged=true"
+        for sv in self.shared_volumes:
+            c << "-v {0}:{1}".format(sv[0], sv[1])
+        if network:
+            c << "--network {0}".format(network)
+        c << "--name {0} --hostname {0} -id {1}".format(
+            self.docker_name(), self.image
+        )
+        self.id = self.dcexec(str(c), retry=True)
+        self.is_running = True
+        self.exec_on_ctn("ip li set up dev lo")
+        ipv4 = None
+        ipv6 = None
+        if network and network != 'none':
+            ifname = self.next_if_name()
+            for line in self.exec_on_ctn(f"ip a show dev {ifname}").split(
+                '\n'
+            ):
+                if line.strip().startswith("inet "):
+                    elems = [e.strip() for e in line.strip().split(' ')]
+                    ipv4 = elems[1]
+                elif line.strip().startswith("inet6 "):
+                    elems = [e.strip() for e in line.strip().split(' ')]
+                    ipv6 = elems[1]
+            self.set_addr_info(
+                bridge='docker0', ipv4=ipv4, ipv6=ipv6, ifname=ifname
+            )
+        return 0
+
+    def wait_for_frr_daemons_up(
+        self,
+        try_times: int = 30,
+        interval: int = 1,
+    ) -> ctn_base.CommandOut:
+        return self.cmd.sudo(
+            f'docker logs {self.docker_name()} '
+            '|grep "WATCHFRR.*all daemons up"',
+            try_times=try_times,
+            interval=interval,
+        )
+
+    @staticmethod
+    def hash_ifname(ifname: str) -> str:
+        # Assuming IFNAMSIZ of 16, with null-termination gives 15 characters.
+        return 'veth' + str(hash(ifname) % 10**11)
+
+    @staticmethod
+    def get_if_mac(ifname: str) -> netaddr.EUI:
+        with open(f'/sys/class/net/{ifname}/address') as faddr:
+            return faddr.readline().rstrip()
+
+    def add_veth_to_bridge(
+        self,
+        bridge_name: str,
+        bridge_type: str,
+        ipv4_cidr: str,
+        ipv6_cidr: str,
+        ipv6_prefix: typing.Optional[netaddr.IPNetwork] = None,
+        vlan: typing.Optional[int] = None,
+    ) -> None:
+        assert self.is_running, (
+            'the container must be running before '
+            'calling add_veth_to_bridge'
+        )
+        assert (
+            bridge_type == ctn_base.BRIDGE_TYPE_OVS
+        ), f'bridge_type must be {ctn_base.BRIDGE_TYPE_OVS}'
+        veth_pair = (
+            self.hash_ifname(f'{self.name}-int{len(self._veths)}'),
+            self.hash_ifname(f'{self.name}-ext{len(self._veths)}'),
+        )
+        self.cmd.sudo(
+            f'ip link add {veth_pair[0]} type veth peer name {veth_pair[1]}'
+        )
+        if ipv6_prefix and not ipv6_cidr:
+            eui = netaddr.EUI(self.get_if_mac(veth_pair[0]))
+            ipv6_cidr = (
+                f'{eui.ipv6(ipv6_prefix.first)}/{ipv6_prefix.prefixlen}'
+            )
+
+        self.cmd.sudo(f'ip link set netns {self.get_pid()} dev {veth_pair[0]}')
+        self.cmd.sudo(f'ovs-vsctl add-port {bridge_name} {veth_pair[1]}')
+        if vlan:
+            self.cmd.sudo(f'ovs-vsctl set port {veth_pair[1]} tag={vlan}')
+
+        ifname = self.next_if_name()
+        self.exec_on_ctn(f'ip link set name {ifname} {veth_pair[0]}')
+
+        # Ensure IPv6 is not disabled in container
+        self.exec_on_ctn('sysctl -w net.ipv6.conf.all.disable_ipv6=0')
+
+        for cidr in (ipv4_cidr, ipv6_cidr):
+            if not cidr:
+                continue
+            self.exec_on_ctn(f'ip addr add {cidr} dev {ifname}')
+        self.exec_on_ctn(f'ip link set up dev {ifname}')
+        self.cmd.sudo(f'ip link set up dev {veth_pair[1]}')
+        self.set_addr_info(
+            bridge_name, ipv4=ipv4_cidr, ipv6=ipv6_cidr, ifname=ifname
+        )
+        self._veths.append(
+            self.veth_info(
+                bridge_name=bridge_name,
+                bridge_type=bridge_type,
+                ctn_ifname=ifname,
+                host_ifname=veth_pair[1],
+            )
+        )
+
+    def add_ctn_route(self, route: route) -> None:
+        self.exec_on_ctn(
+            f'ip -{route.dst.version} route add '
+            f'{str(route.dst.cidr)} via {str(route.next_hop.ip)}'
+        )
+        self._ctn_routes.append(route)
+
+    def del_ctn_route(self, route: route) -> None:
+        self.exec_on_ctn(
+            f'ip -{route.dst.version} route del '
+            f'{str(route.dst.cidr)} via {str(route.next_hop.ip)}'
+        )
+        self._ctn_routes.remove(route)
+
+    def remove(self, check_exist=True) -> ctn_base.CommandOut:
+        for veth in self._veths:
+            # The veth pair itself will be destroyed as a side effect of
+            # removing the container, so we only need to clean up the bridge
+            # attachment.
+            if veth.bridge_type == ctn_base.BRIDGE_TYPE_BRCTL:
+                self.cmd.sudo(
+                    'brctl delif ' f'{veth.bridge_name} ' f'{veth.host_ifname}'
+                )
+            elif veth.bridge_type == ctn_base.BRIDGE_TYPE_OVS:
+                self.cmd.sudo(
+                    'ovs-vsctl del-port '
+                    f'{veth.bridge_name} '
+                    f'{veth.host_ifname}'
+                )
+        super().remove(check_exist=check_exist)
+
+    def vtysh(self, cmd: typing.List[str]) -> ctn_base.CommandOut:
+        cmd_str = ' '.join(f"-c '{c}'" for c in cmd)
+        return self.exec_on_ctn(f'vtysh {cmd_str}', capture=True)
+
+
+class BFDContainer(FRRContainer):
+    def __init__(
+        self,
+        name: str,
+        image: typing.Optional[FRROCIImage] = None,
+    ):
+        image = image or FRROCIImage(
+            daemons=('zebra', 'bfdd'), use_existing=True
+        )
+        super().__init__(name, image)
+        assert 'bfdd' in image.daemons
+
+    def add_bfd_peer(self, ip_address: str) -> None:
+        self.vtysh(
+            [
+                'enable',
+                'conf',
+                'bfd',
+                f'peer {ip_address} interface eth0',
+            ]
+        )
+
+    def del_bfd_peer(self, ip_address: str) -> None:
+        self.vtysh(
+            [
+                'enable',
+                'conf',
+                'bfd',
+                f'no peer {ip_address} interface eth0',
+            ]
+        )
+
+    def show_bfd_peer(self, peer: str) -> typing.Dict[str, typing.Any]:
+        return json.loads(self.vtysh([f'show bfd peer {peer} json']))
+
+    def wait_for_bfd_peer_status(
+        self, peer: str, status: str, try_times=30, interval=1
+    ) -> None:
+        while try_times:
+            peer_data = self.show_bfd_peer(peer)
+            if peer_data['status'] == status:
+                return
+            time.sleep(interval)
+            try_times -= 1
+        raise lib_exc.TimeoutException
+
+
+class NetworkMultipleGWTest(base.BaseAdminTempestTestCase):
+    """Test the following topology
+
+    +------------------------------------------------------------------+
+    |                          test runner                             |
+    |                                                                  |
+    |                                 +-----------+ eth0 public VLAN N |
+    | +-------- br-ex ----------+     | FRR w/BFD |                    |
+    | | +---------------------+ |     +-----------+ eth1 public flat   |
+    | | |   public physnet    | |     +-----------+ eth0 public VLAN N |
+    | | +---------------------+ |     | FRR w/BFD |                    |
+    | +-------------------------+     +-----------+ eth1 public flat   |
+    |     |              |                                             |
+    +-----|--------------|---------------------------------------------+
+          | -  VLAN N  - |
+     +-------------------------+
+     |      project router     | - enable_default_route_{bfd,ecmp}=True
+     +-------------------------+
+                 |
+           +----------+
+           | instance |
+           +----------+
+
+    NOTE(fnordahl) At the time of writing, FRR provides a BFD daemon, but has
+    not integrated it with static routes [0][1].  As a consequence the
+    test will manually add/remove routes on test runner to ensure correct path
+    is chosen for traffic from test runner to instance.  On the return path the
+    BFD implementation in OVN will ensure the correct path is chosen
+    automatically.
+
+    In real world usage most vendors have BFD support for static routes.
+
+    0: https://github.com/FRRouting/frr/wiki/Feature-Requests
+    1: https://github.com/FRRouting/frr/issues/3369
+    """
+    class host_route(typing.NamedTuple):
+        dst: netaddr.IPNetwork
+        next_hop: netaddr.IPNetwork
+
+    host_routes: typing.List[host_route] = []
+
+    credentials = ['primary', 'admin']
+
+    @classmethod
+    def setup_clients(cls):
+        super().setup_clients()
+        if not cls.admin_client:
+            cls.admin_client = cls.os_admin.network_client
+
+    @classmethod
+    @tutils.requires_ext(extension="external-gateway-multihoming",
+                         service="network")
+    def resource_setup(cls):
+        super().resource_setup()
+
+        # Ensure devstack configured public subnets are recorded, so that we
+        # don't attempt to use them again.
+        cls.reserve_external_subnet_cidrs()
+
+        # We need to know prefixlength of the devstack configured public
+        # subnets.
+        for subnet_id in cls.admin_client.show_network(
+                CONF.network.public_network_id)['network']['subnets']:
+            subnet = cls.admin_client.show_subnet(subnet_id)['subnet']
+            if subnet['ip_version'] == 4:
+                cls.public_ipv4_subnet = subnet
+                continue
+            cls.public_ipv6_subnet = subnet
+        cls.ext_networks = []
+        for n in range(0, 2):
+            ext_network = cls.create_provider_network(
+                physnet_name='public',
+                start_segmentation_id=4040 + n,
+                external=True,
+            )
+            ext_ipv6_subnet = cls.create_subnet(
+                ext_network,
+                ip_version=const.IP_VERSION_6,
+                client=cls.admin_client,
+            )
+            ext_ipv4_subnet = cls.create_subnet(
+                ext_network,
+                ip_version=const.IP_VERSION_4,
+                client=cls.admin_client,
+            )
+            cls.ext_networks.append(
+                (ext_network, ext_ipv6_subnet, ext_ipv4_subnet)
+            )
+        cls.host_routes = []
+        cls.resource_setup_container()
+
+    @classmethod
+    def resource_setup_container(cls):
+        cls.containers = []
+        for n in range(0, 2):
+            ext_network, ext_ipv6_subnet, ext_ipv4_subnet = cls.ext_networks[n]
+
+            # frr container
+            bfd_container = BFDContainer(data_utils.rand_name('frr'))
+            cls.containers.append(bfd_container)
+
+            bfd_container.run(network='none')
+            public_ipv6_net = netaddr.IPNetwork(cls.public_ipv6_subnet['cidr'])
+            public_ipv4_net = netaddr.IPNetwork(cls.public_ipv4_subnet['cidr'])
+            ipv6_net = netaddr.IPNetwork(ext_ipv6_subnet['cidr'])
+            ipv4_net = netaddr.IPNetwork(ext_ipv4_subnet['cidr'])
+            # reserve an IP for container on the public network for routing
+            # into the vlan network.
+            fip_address = cls.create_floatingip()['floating_ip_address']
+            cls.veths = [
+                bfd_container.add_veth_to_bridge(
+                    'br-ex',
+                    ctn_base.BRIDGE_TYPE_OVS,
+                    f'{ext_ipv4_subnet["gateway_ip"]}/{ipv4_net.prefixlen}',
+                    f'{ext_ipv6_subnet["gateway_ip"]}/{ipv6_net.prefixlen}',
+                    vlan=ext_network['provider:segmentation_id'],
+                ),
+                bfd_container.add_veth_to_bridge(
+                    'br-ex',
+                    ctn_base.BRIDGE_TYPE_OVS,
+                    f'{fip_address}/{public_ipv4_net.prefixlen}',
+                    '',
+                    ipv6_prefix=public_ipv6_net,
+                ),
+            ]
+            for subnet in (cls.public_ipv4_subnet, cls.public_ipv6_subnet):
+                bfd_container.exec_on_ctn(
+                    f'ip -{subnet["ip_version"]} route add default '
+                    f'via {subnet["gateway_ip"]} dev eth1'
+                )
+            for ip_version in (6, 4):
+                for addr_info in bfd_container.get_addr_info(
+                    'br-ex', ip_version
+                ).items():
+                    if addr_info[1] == 'eth1':
+                        if ip_version == 6:
+                            dst_subnet = ext_ipv6_subnet
+                        else:
+                            dst_subnet = ext_ipv4_subnet
+                        cls.add_host_route(
+                            cls.host_routes,
+                            cls.host_route(
+                                netaddr.IPNetwork(dst_subnet["cidr"]),
+                                netaddr.IPNetwork(addr_info[0]),
+                            ),
+                        )
+            bfd_container.wait_for_frr_daemons_up()
+
+    @classmethod
+    def resource_cleanup(cls):
+        # Ensure common cleanup code can clean up resources created by admin
+        cls.client = cls.admin_client
+        super().resource_cleanup()
+        for ctn in cls.containers:
+            try:
+                ctn.stop()
+            except ctn_base.CommandError:
+                pass
+            ctn.remove()
+        # NOTE(fnordahl): the loop body modifies the list, so we need to
+        # iterate on a copy.
+        for route in cls.host_routes.copy():
+            cls.del_host_route(cls.host_routes, route)
+
+    @staticmethod
+    def add_host_route(
+        lst: typing.List[host_route],
+        route: host_route
+    ) -> None:
+        subprocess.run(
+            (
+                'sudo',
+                'ip',
+                f'-{route.dst.version}',
+                'route',
+                'add',
+                str(route.dst.cidr),
+                'via',
+                str(route.next_hop.ip),
+            ),
+            capture_output=True,
+            check=True,
+            universal_newlines=True,
+        )
+        lst.append(route)
+
+    @staticmethod
+    def del_host_route(
+        lst: typing.List[host_route],
+        route: host_route
+    ) -> None:
+        subprocess.run(
+            (
+                'sudo',
+                'ip',
+                f'-{route.dst.version}',
+                'route',
+                'del',
+                str(route.dst.cidr),
+                'via',
+                str(route.next_hop.ip),
+            ),
+            capture_output=True,
+            check=True,
+            universal_newlines=True,
+        )
+        lst.remove(route)
+
+    def add_ctn_route(
+        self,
+        ctn: BFDContainer,
+        dst: netaddr.IPNetwork,
+        next_hop: netaddr.IPNetwork,
+    ):
+        ctn_route = ctn.route(dst, next_hop)
+        ctn.add_ctn_route(ctn_route)
+        self.per_test_ctn_routes.append((ctn, ctn_route))
+
+    def setUp(self):
+        super().setUp()
+        self.per_test_host_routes = []
+        self.per_test_ctn_routes = []
+
+    def tearDown(self):
+        super().tearDown()
+        # NOTE(fnordahl): the loop body modifies the list, so we need to
+        # iterate on a copy.
+        for ctn_route in self.per_test_ctn_routes.copy():
+            ctn = ctn_route[0]
+            route = ctn_route[1]
+            ctn.del_ctn_route(route)
+        for host_route in self.per_test_host_routes.copy():
+            self.del_host_route(self.per_test_host_routes, host_route)
+
+    def add_routes_for_router(
+        self,
+        router: typing.Dict[str, typing.Any],
+        ctn: FRRContainer,
+        add_ctn_route: bool = True,
+        add_host_route: bool = True,
+    ):
+        for port in self.admin_client.list_router_interfaces(router['id'])[
+            'ports'
+        ]:
+            if port['device_owner'] != const.DEVICE_OWNER_ROUTER_INTF:
+                continue
+            for fixed_ip in port['fixed_ips']:
+                subnet = self.client.show_subnet(
+                    fixed_ip['subnet_id'])['subnet']
+                for addr_info in ctn.get_addr_info(
+                    'br-ex',
+                    subnet['ip_version'],
+                ).items():
+                    if addr_info[1] == 'eth0':
+                        # container route
+                        ctn_net = netaddr.IPNetwork(addr_info[0])
+                        for gw_info in router['external_gateways']:
+                            for ip_info in gw_info['external_fixed_ips']:
+                                if (
+                                    ip_info['ip_address'] in ctn_net and
+                                    add_ctn_route
+                                ):
+                                    self.add_ctn_route(
+                                        ctn,
+                                        netaddr.IPNetwork(subnet['cidr']),
+                                        netaddr.IPNetwork(
+                                            ip_info['ip_address']
+                                        ),
+                                    )
+                    elif addr_info[1] == 'eth1' and add_host_route:
+                        self.add_host_route(
+                            self.per_test_host_routes,
+                            self.host_route(
+                                netaddr.IPNetwork(self.subnet['cidr']),
+                                netaddr.IPNetwork(addr_info[0]),
+                            ),
+                        )
+
+    @testtools.skipUnless(
+        CONF.compute.min_compute_nodes == 1,
+        'More than 1 compute node, test only works on '
+        'single node configurations.',
+    )
+    @decorators.idempotent_id('9baa05e6-ba10-4850-93e3-695f4d97b8f8')
+    def test_create_router_single_gw_bfd(self):
+        ext_network_id = self.ext_networks[0][0]['id']
+        bfd_container = self.containers[0]
+        router = self.create_admin_router(
+            router_name=data_utils.rand_name('router'),
+            admin_state_up=True,
+            enable_snat=False,
+            enable_default_route_bfd=True,
+            external_network_id=ext_network_id,
+        )
+        self.assertTrue(router['enable_default_route_bfd'])
+
+        # Add BFD peers on bfd_container.
+        for gw_info in router['external_gateways']:
+            for ip_info in gw_info['external_fixed_ips']:
+                bfd_container.add_bfd_peer(ip_info["ip_address"])
+                bfd_container.wait_for_bfd_peer_status(
+                    ip_info['ip_address'], 'up'
+                )
+
+        self.setup_network_and_server(
+            router=router,
+            create_fip=False,
+            router_client=self.admin_client,
+        )
+
+        self.add_routes_for_router(router, bfd_container)
+
+        # check connectivity
+        self.check_connectivity(
+            self.port['fixed_ips'][0]['ip_address'],
+            CONF.validation.image_ssh_user,
+            self.keypair['private_key'],
+        )
+
+    @testtools.skipUnless(
+        CONF.compute.min_compute_nodes == 1,
+        'More than 1 compute node, test only works on '
+        'single node configurations.',
+    )
+    @decorators.idempotent_id('75202251-c384-4962-8685-60cf2c530906')
+    def test_update_router_single_gw_bfd(self):
+        ext_network_id = self.ext_networks[0][0]['id']
+        bfd_container = self.containers[0]
+        router = self.create_router(
+            router_name=data_utils.rand_name('router'),
+            admin_state_up=True,
+            enable_snat=False,
+            external_network_id=ext_network_id,
+        )
+        self.assertFalse(router['enable_default_route_bfd'])
+
+        self.setup_network_and_server(
+            router=router,
+            create_fip=False,
+            router_client=self.admin_client,
+        )
+
+        self.add_routes_for_router(router, bfd_container)
+
+        # check connectivity
+        self.check_connectivity(
+            self.port['fixed_ips'][0]['ip_address'],
+            CONF.validation.image_ssh_user,
+            self.keypair['private_key'],
+        )
+
+        # Enable BFD on router.
+        #
+        # NOTE(fnordahl): We need to repeat the `enable_snat` state, otherwise
+        # the state will be toggled to the default value of 'True'.
+        router = self.admin_client.update_router_with_snat_gw_info(
+            router['id'],
+            enable_snat=False,
+            enable_default_route_bfd=True,
+        )['router']
+        self.assertTrue(router['enable_default_route_bfd'])
+
+        # Add BFD peers on bfd_container.
+        for gw_info in router['external_gateways']:
+            for ip_info in gw_info['external_fixed_ips']:
+                bfd_container.add_bfd_peer(ip_info["ip_address"])
+                bfd_container.wait_for_bfd_peer_status(
+                    ip_info['ip_address'], 'up'
+                )
+
+        # check connectivity
+        self.check_connectivity(
+            self.port['fixed_ips'][0]['ip_address'],
+            CONF.validation.image_ssh_user,
+            self.keypair['private_key'],
+        )
+
+    @testtools.skipUnless(
+        CONF.compute.min_compute_nodes == 1,
+        'More than 1 compute node, test only works on '
+        'single node configurations.',
+    )
+    @decorators.idempotent_id('5117587d-9633-48b7-aa8f-ec9d59a601a5')
+    def test_create_router_multiple_gw_bfd_and_ecmp(self):
+        router = self.create_admin_router(
+            router_name=data_utils.rand_name('router'),
+            admin_state_up=True,
+            enable_default_route_bfd=True,
+            enable_default_route_ecmp=True,
+        )
+        router = self.admin_client.router_add_external_gateways(
+            router['id'],
+            [
+                {
+                    'network_id': self.ext_networks[0][0]['id'],
+                    'enable_snat': False,
+                },
+                {
+                    'network_id': self.ext_networks[1][0]['id'],
+                    'enable_snat': False,
+                },
+            ],
+        )['router']
+
+        self.setup_network_and_server(
+            router=router,
+            create_fip=False,
+            router_client=self.admin_client,
+        )
+
+        # Add BFD peers on bfd_containers.
+        for gw_info in router['external_gateways']:
+            for ip_info in gw_info['external_fixed_ips']:
+                ip = netaddr.IPAddress(ip_info['ip_address'])
+                for ctn in self.containers:
+                    for addr_info in ctn.get_addr_info(
+                        'br-ex',
+                        ip.version,
+                    ).items():
+                        if addr_info[1] == 'eth0':
+                            ctn_net = netaddr.IPNetwork(addr_info[0])
+                            if ip not in ctn_net:
+                                break
+                            ctn.add_bfd_peer(str(ip))
+                            ctn.wait_for_bfd_peer_status(str(ip), 'up')
+
+        # Add route to project network on all containers.
+        for ctn in self.containers:
+            self.add_routes_for_router(router, ctn, True, False)
+
+        # Add host route to project network via FRR container and confirm
+        # connectivity one by one.
+        #
+        # We deliberately don't add both host routes at once as that would be
+        # testing test runner configuration and linux kernel ECMP, which is out
+        # of scope for our test.
+        for ctn in self.containers:
+            self.add_routes_for_router(router, ctn, False, True)
+
+            # check connectivity
+            self.check_connectivity(
+                self.port['fixed_ips'][0]['ip_address'],
+                CONF.validation.image_ssh_user,
+                self.keypair['private_key'],
+            )
+            for host_route in self.per_test_host_routes.copy():
+                self.del_host_route(self.per_test_host_routes, host_route)
diff --git a/neutron_tempest_plugin/services/network/json/network_client.py b/neutron_tempest_plugin/services/network/json/network_client.py
index 1153a2a..289ef61 100644
--- a/neutron_tempest_plugin/services/network/json/network_client.py
+++ b/neutron_tempest_plugin/services/network/json/network_client.py
@@ -804,6 +804,14 @@
         body = jsonutils.loads(body)
         return service_client.ResponseBody(resp, body)
 
+    def validate_auto_allocated_topology_requirements(self, tenant_id=None):
+        uri = '%s/auto-allocated-topology/%s?fields=dry-run' % (
+            self.uri_prefix, tenant_id)
+        resp, body = self.get(uri)
+        self.expected_success(200, resp.status)
+        body = jsonutils.loads(body)
+        return service_client.ResponseBody(resp, body)
+
     def get_auto_allocated_topology(self, tenant_id=None):
         uri = '%s/auto-allocated-topology/%s' % (self.uri_prefix, tenant_id)
         resp, body = self.get(uri)
diff --git a/playbooks/plugin-ovn-scenario-pre-run.yaml b/playbooks/plugin-ovn-scenario-pre-run.yaml
new file mode 100644
index 0000000..925223e
--- /dev/null
+++ b/playbooks/plugin-ovn-scenario-pre-run.yaml
@@ -0,0 +1,3 @@
+- hosts: all
+  roles:
+    - docker-setup
diff --git a/zuul.d/2023_1_jobs.yaml b/zuul.d/2023_1_jobs.yaml
index 6bf7027..4325b83 100644
--- a/zuul.d/2023_1_jobs.yaml
+++ b/zuul.d/2023_1_jobs.yaml
@@ -95,6 +95,7 @@
         - uplink-status-propagation
       devstack_localrc:
         NETWORK_API_EXTENSIONS: "{{ (network_api_extensions_common + network_api_extensions_openvswitch) | join(',') }}"
+        NEUTRON_DEPLOY_MOD_WSGI: false
       devstack_local_conf:
         test-config:
           $TEMPEST_CONFIG:
@@ -129,6 +130,7 @@
           (^neutron_tempest_plugin.scenario.test_security_groups.StatelessNetworkSecGroupTest.test_established_tcp_session_after_re_attachinging_sg)"
       devstack_localrc:
         NETWORK_API_EXTENSIONS: "{{ (network_api_extensions_common + network_api_extensions_openvswitch) | join(',') }}"
+        NEUTRON_DEPLOY_MOD_WSGI: false
       devstack_local_conf:
         test-config:
           $TEMPEST_CONFIG:
@@ -238,6 +240,7 @@
         - dvr
       devstack_localrc:
         NETWORK_API_EXTENSIONS: "{{ (network_api_extensions_common + network_api_extensions_dvr) | join(',') }}"
+        NEUTRON_DEPLOY_MOD_WSGI: false
 
 - job:
     name: neutron-tempest-plugin-designate-scenario-2023-1
@@ -250,11 +253,17 @@
     name: neutron-tempest-plugin-sfc-2023-1
     parent: neutron-tempest-plugin-sfc
     override-checkout: stable/2023.1
+    vars:
+      devstack_localrc:
+        NEUTRON_DEPLOY_MOD_WSGI: false
 
 - job:
     name: neutron-tempest-plugin-bgpvpn-bagpipe-2023-1
     parent: neutron-tempest-plugin-bgpvpn-bagpipe
     override-checkout: stable/2023.1
+    vars:
+      devstack_localrc:
+        NEUTRON_DEPLOY_MOD_WSGI: false
 
 - job:
     name: neutron-tempest-plugin-dynamic-routing-2023-1
@@ -265,13 +274,22 @@
     name: neutron-tempest-plugin-fwaas-2023-1
     parent: neutron-tempest-plugin-fwaas
     override-checkout: stable/2023.1
+    vars:
+      devstack_localrc:
+        NEUTRON_DEPLOY_MOD_WSGI: false
 
 - job:
     name: neutron-tempest-plugin-vpnaas-2023-1
     parent: neutron-tempest-plugin-vpnaas
     override-checkout: stable/2023.1
+    vars:
+      devstack_localrc:
+        NEUTRON_DEPLOY_MOD_WSGI: false
 
 - job:
     name: neutron-tempest-plugin-tap-as-a-service-2023-1
     parent: neutron-tempest-plugin-tap-as-a-service
     override-checkout: stable/2023.1
+    vars:
+      devstack_localrc:
+        NEUTRON_DEPLOY_MOD_WSGI: false
diff --git a/zuul.d/2023_2_jobs.yaml b/zuul.d/2023_2_jobs.yaml
index 87092a9..7406440 100644
--- a/zuul.d/2023_2_jobs.yaml
+++ b/zuul.d/2023_2_jobs.yaml
@@ -95,6 +95,7 @@
         - uplink-status-propagation
       devstack_localrc:
         NETWORK_API_EXTENSIONS: "{{ (network_api_extensions_common + network_api_extensions_openvswitch) | join(',') }}"
+        NEUTRON_DEPLOY_MOD_WSGI: false
       devstack_local_conf:
         test-config:
           $TEMPEST_CONFIG:
@@ -124,6 +125,7 @@
           (^neutron_tempest_plugin.scenario.test_security_groups.StatelessNetworkSecGroupTest.test_established_tcp_session_after_re_attachinging_sg)"
       devstack_localrc:
         NETWORK_API_EXTENSIONS: "{{ (network_api_extensions_common + network_api_extensions_openvswitch) | join(',') }}"
+        NEUTRON_DEPLOY_MOD_WSGI: false
       devstack_local_conf:
         test-config:
           $TEMPEST_CONFIG:
@@ -233,6 +235,7 @@
         - dvr
       devstack_localrc:
         NETWORK_API_EXTENSIONS: "{{ (network_api_extensions_common + network_api_extensions_dvr) | join(',') }}"
+        NEUTRON_DEPLOY_MOD_WSGI: false
 
 - job:
     name: neutron-tempest-plugin-designate-scenario-2023-2
@@ -245,11 +248,17 @@
     name: neutron-tempest-plugin-sfc-2023-2
     parent: neutron-tempest-plugin-sfc
     override-checkout: stable/2023.2
+    vars:
+      devstack_localrc:
+        NEUTRON_DEPLOY_MOD_WSGI: false
 
 - job:
     name: neutron-tempest-plugin-bgpvpn-bagpipe-2023-2
     parent: neutron-tempest-plugin-bgpvpn-bagpipe
     override-checkout: stable/2023.2
+    vars:
+      devstack_localrc:
+        NEUTRON_DEPLOY_MOD_WSGI: false
 
 - job:
     name: neutron-tempest-plugin-dynamic-routing-2023-2
@@ -260,13 +269,22 @@
     name: neutron-tempest-plugin-fwaas-2023-2
     parent: neutron-tempest-plugin-fwaas
     override-checkout: stable/2023.2
+    vars:
+      devstack_localrc:
+        NEUTRON_DEPLOY_MOD_WSGI: false
 
 - job:
     name: neutron-tempest-plugin-vpnaas-2023-2
     parent: neutron-tempest-plugin-vpnaas
     override-checkout: stable/2023.2
+    vars:
+      devstack_localrc:
+        NEUTRON_DEPLOY_MOD_WSGI: false
 
 - job:
     name: neutron-tempest-plugin-tap-as-a-service-2023-2
     parent: neutron-tempest-plugin-tap-as-a-service
     override-checkout: stable/2023.2
+    vars:
+      devstack_localrc:
+        NEUTRON_DEPLOY_MOD_WSGI: false
diff --git a/zuul.d/2024_1_jobs.yaml b/zuul.d/2024_1_jobs.yaml
index 022e439..f0a182b 100644
--- a/zuul.d/2024_1_jobs.yaml
+++ b/zuul.d/2024_1_jobs.yaml
@@ -97,6 +97,7 @@
         - uplink-status-propagation
       devstack_localrc:
         NETWORK_API_EXTENSIONS: "{{ (network_api_extensions_common + network_api_extensions_openvswitch) | join(',') }}"
+        NEUTRON_DEPLOY_MOD_WSGI: false
       devstack_local_conf:
         test-config:
           $TEMPEST_CONFIG:
@@ -126,6 +127,7 @@
           (^neutron_tempest_plugin.scenario.test_security_groups.StatelessNetworkSecGroupTest.test_established_tcp_session_after_re_attachinging_sg)"
       devstack_localrc:
         NETWORK_API_EXTENSIONS: "{{ (network_api_extensions_common + network_api_extensions_openvswitch) | join(',') }}"
+        NEUTRON_DEPLOY_MOD_WSGI: false
       devstack_local_conf:
         test-config:
           $TEMPEST_CONFIG:
@@ -232,6 +234,7 @@
         - dvr
       devstack_localrc:
         NETWORK_API_EXTENSIONS: "{{ (network_api_extensions_common + network_api_extensions_dvr) | join(',') }}"
+        NEUTRON_DEPLOY_MOD_WSGI: false
 
 - job:
     name: neutron-tempest-plugin-designate-scenario-2024-1
@@ -244,11 +247,17 @@
     name: neutron-tempest-plugin-sfc-2024-1
     parent: neutron-tempest-plugin-sfc
     override-checkout: stable/2024.1
+    vars:
+      devstack_localrc:
+        NEUTRON_DEPLOY_MOD_WSGI: false
 
 - job:
     name: neutron-tempest-plugin-bgpvpn-bagpipe-2024-1
     parent: neutron-tempest-plugin-bgpvpn-bagpipe
     override-checkout: stable/2024.1
+    vars:
+      devstack_localrc:
+        NEUTRON_DEPLOY_MOD_WSGI: false
 
 - job:
     name: neutron-tempest-plugin-dynamic-routing-2024-1
@@ -259,13 +268,22 @@
     name: neutron-tempest-plugin-fwaas-2024-1
     parent: neutron-tempest-plugin-fwaas
     override-checkout: stable/2024.1
+    vars:
+      devstack_localrc:
+        NEUTRON_DEPLOY_MOD_WSGI: false
 
 - job:
     name: neutron-tempest-plugin-vpnaas-2024-1
     parent: neutron-tempest-plugin-vpnaas
     override-checkout: stable/2024.1
+    vars:
+      devstack_localrc:
+        NEUTRON_DEPLOY_MOD_WSGI: false
 
 - job:
     name: neutron-tempest-plugin-tap-as-a-service-2024-1
     parent: neutron-tempest-plugin-tap-as-a-service
     override-checkout: stable/2024.1
+    vars:
+      devstack_localrc:
+        NEUTRON_DEPLOY_MOD_WSGI: false
diff --git a/zuul.d/base-nested-switch.yaml b/zuul.d/base-nested-switch.yaml
index 8533e07..f8fa799 100644
--- a/zuul.d/base-nested-switch.yaml
+++ b/zuul.d/base-nested-switch.yaml
@@ -34,9 +34,10 @@
         # NOTE(ykarel): seeing issues with host-passthrough mode
         # https://bugs.launchpad.net/neutron/+bug/2036603
         # LIBVIRT_CPU_MODE: host-passthrough
-        CIRROS_VERSION: 0.6.2
-        DEFAULT_IMAGE_NAME: cirros-0.6.2-x86_64-disk
-        DEFAULT_IMAGE_FILE_NAME: cirros-0.6.2-x86_64-disk.img
+        # TODO(ykarel): switch to 0.6.2+ once lp#2045549 is fixed
+        CIRROS_VERSION: 0.5.3
+        DEFAULT_IMAGE_NAME: cirros-0.5.3-x86_64-disk
+        DEFAULT_IMAGE_FILE_NAME: cirros-0.5.3-x86_64-disk.img
 
 # Base nested switch job for yoga and zed
 - job:
diff --git a/zuul.d/master_jobs.yaml b/zuul.d/master_jobs.yaml
index 28f5082..d6bb174 100644
--- a/zuul.d/master_jobs.yaml
+++ b/zuul.d/master_jobs.yaml
@@ -235,7 +235,7 @@
     parent: neutron-tempest-plugin-base-nested-switch
     timeout: 10000
     vars:
-      configure_swap_size: 2048
+      configure_swap_size: 3072
       devstack_services:
         # Disable OVN services
         br-ex-tcpdump: false
@@ -264,6 +264,7 @@
         Q_ML2_TENANT_NETWORK_TYPE: vxlan
         Q_ML2_PLUGIN_MECHANISM_DRIVERS: openvswitch
         NETWORK_API_EXTENSIONS: "{{ (network_api_extensions_common + network_api_extensions_openvswitch) | join(',') }}"
+        NEUTRON_DEPLOY_MOD_WSGI: true
       devstack_local_conf:
         post-config:
           $NEUTRON_CONF:
@@ -341,7 +342,7 @@
     parent: neutron-tempest-plugin-base-nested-switch
     timeout: 10000
     vars:
-      configure_swap_size: 2048
+      configure_swap_size: 3072
       devstack_services:
         # Disable OVN services
         br-ex-tcpdump: false
@@ -377,6 +378,7 @@
         Q_ML2_TENANT_NETWORK_TYPE: vxlan
         Q_ML2_PLUGIN_MECHANISM_DRIVERS: openvswitch
         NETWORK_API_EXTENSIONS: "{{ (network_api_extensions_common + network_api_extensions_openvswitch) | join(',') }}"
+        NEUTRON_DEPLOY_MOD_WSGI: true
       devstack_local_conf:
         post-config:
           $NEUTRON_CONF:
@@ -509,7 +511,7 @@
       - zuul: openstack/neutron
     pre-run: playbooks/linuxbridge-scenario-pre-run.yaml
     vars:
-      configure_swap_size: 2048
+      configure_swap_size: 3072
       devstack_services:
         # Disable OVN services
         br-ex-tcpdump: false
@@ -637,9 +639,11 @@
     name: neutron-tempest-plugin-ovn
     parent: neutron-tempest-plugin-base-nested-switch
     timeout: 10800
+    pre-run: playbooks/plugin-ovn-scenario-pre-run.yaml
     vars:
       network_api_extensions_ovn:
         - vlan-transparent
+        - external-gateway-multihoming
       # TODO(jlibosva): Remove the NetworkWritableMtuTest test from the list
       # once east/west fragmentation is supported in core OVN
       tempest_exclude_regex: "\
@@ -701,6 +705,8 @@
           /$NEUTRON_CORE_PLUGIN_CONF:
             ml2:
               type_drivers: local,flat,vlan,geneve
+            ml2_type_vlan:
+              network_vlan_ranges: foo:1:10,public
         test-config:
           $TEMPEST_CONFIG:
             network-feature-enabled:
@@ -712,6 +718,8 @@
       zuul_copy_output:
         '{{ devstack_base_dir }}/data/ovs': 'logs'
         '{{ devstack_base_dir }}/data/ovn': 'logs'
+        '{{ devstack_log_dir }}/ovn-controller.log': 'logs'
+        '{{ devstack_log_dir }}/ovn-northd.log': 'logs'
         '{{ devstack_log_dir }}/ovsdb-server-nb.log': 'logs'
         '{{ devstack_log_dir }}/ovsdb-server-sb.log': 'logs'
         '/var/log/ovn': 'logs'
@@ -782,6 +790,22 @@
     parent: neutron-tempest-plugin-ovn
 
 - job:
+    name: neutron-tempest-plugin-api-ovs-wsgi
+    parent: neutron-tempest-plugin-openvswitch
+    voting: false
+    vars:
+      devstack_localrc:
+        NEUTRON_DEPLOY_MOD_WSGI: true
+
+- job:
+    name: neutron-tempest-plugin-api-ovn-wsgi
+    parent: neutron-tempest-plugin-ovn
+    voting: false
+    vars:
+      devstack_localrc:
+        NEUTRON_DEPLOY_MOD_WSGI: true
+
+- job:
     name: neutron-tempest-plugin-dvr-multinode-scenario
     parent: tempest-multinode-full
     description: |
@@ -822,6 +846,7 @@
         Q_AGENT: openvswitch
         Q_ML2_TENANT_NETWORK_TYPE: vxlan
         Q_ML2_PLUGIN_MECHANISM_DRIVERS: openvswitch
+        NEUTRON_DEPLOY_MOD_WSGI: true
       devstack_plugins:
         neutron: https://opendev.org/openstack/neutron.git
         neutron-tempest-plugin: https://opendev.org/openstack/neutron-tempest-plugin.git
@@ -1149,6 +1174,9 @@
         Q_ML2_TENANT_NETWORK_TYPE: vxlan
         Q_ML2_PLUGIN_MECHANISM_DRIVERS: openvswitch
         NETWORK_API_EXTENSIONS: "{{ (network_api_extensions_common + network_api_extensions_sfc) | join(',') }}"
+        # TODO(slaweq): Switch it back to be deployed with mod_wsgi as soon as
+        # bug with RPC worker will be resolved
+        NEUTRON_DEPLOY_MOD_WSGI: false
       # TODO(bcafarel): tests still fail from time to time in parallel
       # https://bugs.launchpad.net/neutron/+bug/1851500
       # https://bugs.launchpad.net/networking-sfc/+bug/1660366
@@ -1228,6 +1256,7 @@
         BAGPIPE_DATAPLANE_DRIVER_IPVPN: "ovs"
         BAGPIPE_BGP_PEERS: "-"
         NETWORK_API_EXTENSIONS: "{{ (network_api_extensions_common + network_api_extensions_bgpvpn) | join(',') }}"
+        NEUTRON_DEPLOY_MOD_WSGI: true
       devstack_plugins:
         networking-bgpvpn: https://git.openstack.org/openstack/networking-bgpvpn
         networking-bagpipe: https://git.openstack.org/openstack/networking-bagpipe
@@ -1357,6 +1386,7 @@
         Q_AGENT: openvswitch
         Q_ML2_TENANT_NETWORK_TYPE: vxlan
         Q_ML2_PLUGIN_MECHANISM_DRIVERS: openvswitch
+        NEUTRON_DEPLOY_MOD_WSGI: true
       devstack_services:
         # Disable OVN services
         br-ex-tcpdump: false
@@ -1434,6 +1464,7 @@
         Q_AGENT: openvswitch
         Q_ML2_TENANT_NETWORK_TYPE: vxlan
         Q_ML2_PLUGIN_MECHANISM_DRIVERS: openvswitch
+        NEUTRON_DEPLOY_MOD_WSGI: true
       devstack_services:
         # Disable OVN services
         br-ex-tcpdump: false
@@ -1584,6 +1615,7 @@
         Q_AGENT: openvswitch
         Q_ML2_TENANT_NETWORK_TYPE: vxlan,vlan
         Q_ML2_PLUGIN_MECHANISM_DRIVERS: openvswitch
+        NEUTRON_DEPLOY_MOD_WSGI: true
       devstack_local_conf:
         post-config:
           /$NEUTRON_CORE_PLUGIN_CONF:
diff --git a/zuul.d/project.yaml b/zuul.d/project.yaml
index 04f5f79..35f3384 100644
--- a/zuul.d/project.yaml
+++ b/zuul.d/project.yaml
@@ -6,7 +6,10 @@
         - neutron-tempest-plugin-openvswitch-iptables_hybrid
         - neutron-tempest-plugin-openvswitch-enforce-scope-old-defaults
         - neutron-tempest-plugin-ovn
-        - neutron-tempest-plugin-designate-scenario
+        - neutron-tempest-plugin-designate-scenario:
+            # TODO(slaweq) make job voting again once bug
+            # https://bugs.launchpad.net/designate/+bug/2072627 will be fixed
+            voting: false
     gate:
       jobs:
         - neutron-tempest-plugin-openvswitch
@@ -21,6 +24,8 @@
         - neutron-tempest-plugin-dvr-multinode-scenario
         - neutron-tempest-plugin-openvswitch-distributed-dhcp
         - neutron-tempest-plugin-openvswitch-iptables_hybrid-distributed-dhcp
+        - neutron-tempest-plugin-api-ovs-wsgi
+        - neutron-tempest-plugin-api-ovn-wsgi
 
 
 - project-template:
diff --git a/zuul.d/victoria_jobs.yaml b/zuul.d/victoria_jobs.yaml
index d98de84..291e01d 100644
--- a/zuul.d/victoria_jobs.yaml
+++ b/zuul.d/victoria_jobs.yaml
@@ -151,6 +151,7 @@
         ADVANCED_INSTANCE_TYPE: ds512M
         ADVANCED_INSTANCE_USER: ubuntu
         CUSTOMIZE_IMAGE: false
+        NEUTRON_DEPLOY_MOD_WSGI: false
       devstack_local_conf:
         test-config:
           $TEMPEST_CONFIG:
@@ -183,6 +184,7 @@
         ADVANCED_INSTANCE_TYPE: ds512M
         ADVANCED_INSTANCE_USER: ubuntu
         CUSTOMIZE_IMAGE: false
+        NEUTRON_DEPLOY_MOD_WSGI: false
       devstack_local_conf:
         test-config:
           $TEMPEST_CONFIG:
@@ -257,6 +259,8 @@
     required-projects: *required-projects-victoria
     vars:
       network_api_extensions_common: *api_extensions
+      devstack_localrc:
+        NEUTRON_DEPLOY_MOD_WSGI: false
 
 - job:
     name: neutron-tempest-plugin-designate-scenario-victoria
@@ -278,6 +282,7 @@
         ADVANCED_INSTANCE_TYPE: ds512M
         ADVANCED_INSTANCE_USER: ubuntu
         CUSTOMIZE_IMAGE: false
+        NEUTRON_DEPLOY_MOD_WSGI: false
 
 - job:
     name: neutron-tempest-plugin-sfc-victoria
@@ -287,6 +292,8 @@
     required-projects: *required-projects-victoria
     vars:
       network_api_extensions_common: *api_extensions
+      devstack_localrc:
+        NEUTRON_DEPLOY_MOD_WSGI: false
 
 - job:
     name: neutron-tempest-plugin-bgpvpn-bagpipe-victoria
@@ -296,6 +303,8 @@
     required-projects: *required-projects-victoria
     vars:
       network_api_extensions: *api_extensions
+      devstack_localrc:
+        NEUTRON_DEPLOY_MOD_WSGI: false
 
 - job:
     name: neutron-tempest-plugin-dynamic-routing-victoria
@@ -314,3 +323,5 @@
     required-projects: *required-projects-victoria
     vars:
       network_api_extensions_common: *api_extensions
+      devstack_localrc:
+        NEUTRON_DEPLOY_MOD_WSGI: false
diff --git a/zuul.d/wallaby_jobs.yaml b/zuul.d/wallaby_jobs.yaml
index 48b56b2..7df6876 100644
--- a/zuul.d/wallaby_jobs.yaml
+++ b/zuul.d/wallaby_jobs.yaml
@@ -109,6 +109,7 @@
       network_available_features: *available_features
       devstack_localrc:
         NETWORK_API_EXTENSIONS: "{{ network_api_extensions | join(',') }}"
+        NEUTRON_DEPLOY_MOD_WSGI: false
       devstack_local_conf:
         test-config:
           $TEMPEST_CONFIG:
@@ -136,6 +137,7 @@
           (^neutron_tempest_plugin.scenario.test_security_groups.NetworkSecGroupTest.test_established_tcp_session_after_re_attachinging_sg)"
       devstack_localrc:
         NETWORK_API_EXTENSIONS: "{{ network_api_extensions | join(',') }}"
+        NEUTRON_DEPLOY_MOD_WSGI: false
       devstack_local_conf:
         test-config:
           $TEMPEST_CONFIG:
@@ -194,6 +196,8 @@
     required-projects: *required-projects-wallaby
     vars:
       network_api_extensions_common: *api_extensions
+      devstack_localrc:
+        NEUTRON_DEPLOY_MOD_WSGI: false
 
 - job:
     name: neutron-tempest-plugin-designate-scenario-wallaby
@@ -217,6 +221,8 @@
     required-projects: *required-projects-wallaby
     vars:
       network_api_extensions_common: *api_extensions
+      devstack_localrc:
+        NEUTRON_DEPLOY_MOD_WSGI: false
 
 - job:
     name: neutron-tempest-plugin-bgpvpn-bagpipe-wallaby
@@ -226,6 +232,8 @@
     required-projects: *required-projects-wallaby
     vars:
       network_api_extensions: *api_extensions
+      devstack_localrc:
+        NEUTRON_DEPLOY_MOD_WSGI: false
 
 - job:
     name: neutron-tempest-plugin-dynamic-routing-wallaby
@@ -244,3 +252,5 @@
     required-projects: *required-projects-wallaby
     vars:
       network_api_extensions_common: *api_extensions
+      devstack_localrc:
+        NEUTRON_DEPLOY_MOD_WSGI: false
diff --git a/zuul.d/xena_jobs.yaml b/zuul.d/xena_jobs.yaml
index fc0b84f..e1e13c4 100644
--- a/zuul.d/xena_jobs.yaml
+++ b/zuul.d/xena_jobs.yaml
@@ -111,6 +111,7 @@
       network_available_features: *available_features
       devstack_localrc:
         NETWORK_API_EXTENSIONS: "{{ network_api_extensions | join(',') }}"
+        NEUTRON_DEPLOY_MOD_WSGI: false
       devstack_local_conf:
         test-config:
           $TEMPEST_CONFIG:
@@ -131,6 +132,7 @@
       network_available_features: *available_features
       devstack_localrc:
         NETWORK_API_EXTENSIONS: "{{ network_api_extensions | join(',') }}"
+        NEUTRON_DEPLOY_MOD_WSGI: false
       devstack_local_conf:
         test-config:
           $TEMPEST_CONFIG:
@@ -187,6 +189,8 @@
     required-projects: *required-projects-xena
     vars:
       network_api_extensions_common: *api_extensions
+      devstack_localrc:
+        NEUTRON_DEPLOY_MOD_WSGI: false
 
 - job:
     name: neutron-tempest-plugin-designate-scenario-xena
@@ -204,6 +208,8 @@
     required-projects: *required-projects-xena
     vars:
       network_api_extensions_common: *api_extensions
+      devstack_localrc:
+        NEUTRON_DEPLOY_MOD_WSGI: false
 
 - job:
     name: neutron-tempest-plugin-bgpvpn-bagpipe-xena
@@ -213,6 +219,8 @@
     required-projects: *required-projects-xena
     vars:
       network_api_extensions: *api_extensions
+      devstack_localrc:
+        NEUTRON_DEPLOY_MOD_WSGI: false
 
 - job:
     name: neutron-tempest-plugin-dynamic-routing-xena
@@ -227,6 +235,7 @@
         Q_AGENT: openvswitch
         Q_ML2_TENANT_NETWORK_TYPE: vxlan
         Q_ML2_PLUGIN_MECHANISM_DRIVERS: openvswitch
+        NEUTRON_DEPLOY_MOD_WSGI: false
       devstack_services:
         # Disable OVN services
         br-ex-tcpdump: false
@@ -253,6 +262,8 @@
     required-projects: *required-projects-xena
     vars:
       network_api_extensions_common: *api_extensions
+      devstack_localrc:
+        NEUTRON_DEPLOY_MOD_WSGI: false
 
 - job:
     name: neutron-tempest-plugin-tap-as-a-service-xena
@@ -262,3 +273,5 @@
     required-projects: *required-projects-xena
     vars:
       network_api_extensions_common: *api_extensions
+      devstack_localrc:
+        NEUTRON_DEPLOY_MOD_WSGI: false
diff --git a/zuul.d/yoga_jobs.yaml b/zuul.d/yoga_jobs.yaml
index af7f97e..6e0ddcd 100644
--- a/zuul.d/yoga_jobs.yaml
+++ b/zuul.d/yoga_jobs.yaml
@@ -114,6 +114,7 @@
       network_available_features: *available_features
       devstack_localrc:
         NETWORK_API_EXTENSIONS: "{{ (network_api_extensions + network_api_extensions_openvswitch) | join(',') }}"
+        NEUTRON_DEPLOY_MOD_WSGI: false
       devstack_local_conf:
         test-config:
           $TEMPEST_CONFIG:
@@ -135,6 +136,7 @@
       network_available_features: *available_features
       devstack_localrc:
         NETWORK_API_EXTENSIONS: "{{ (network_api_extensions + network_api_extensions_openvswitch) | join(',') }}"
+        NEUTRON_DEPLOY_MOD_WSGI: false
       devstack_local_conf:
         test-config:
           $TEMPEST_CONFIG:
@@ -195,6 +197,8 @@
     required-projects: *required-projects-yoga
     vars:
       network_api_extensions_common: *api_extensions
+      devstack_localrc:
+        NEUTRON_DEPLOY_MOD_WSGI: false
 
 - job:
     name: neutron-tempest-plugin-designate-scenario-yoga
@@ -213,6 +217,8 @@
     required-projects: *required-projects-yoga
     vars:
       network_api_extensions_common: *api_extensions
+      devstack_localrc:
+        NEUTRON_DEPLOY_MOD_WSGI: false
 
 - job:
     name: neutron-tempest-plugin-bgpvpn-bagpipe-yoga
@@ -222,6 +228,8 @@
     required-projects: *required-projects-yoga
     vars:
       network_api_extensions: *api_extensions
+      devstack_localrc:
+        NEUTRON_DEPLOY_MOD_WSGI: false
 
 - job:
     name: neutron-tempest-plugin-dynamic-routing-yoga
@@ -236,6 +244,7 @@
         Q_AGENT: openvswitch
         Q_ML2_TENANT_NETWORK_TYPE: vxlan
         Q_ML2_PLUGIN_MECHANISM_DRIVERS: openvswitch
+        NEUTRON_DEPLOY_MOD_WSGI: false
       devstack_services:
         # Disable OVN services
         br-ex-tcpdump: false
@@ -262,6 +271,8 @@
     required-projects: *required-projects-yoga
     vars:
       network_api_extensions_common: *api_extensions
+      devstack_localrc:
+        NEUTRON_DEPLOY_MOD_WSGI: false
 
 - job:
     name: neutron-tempest-plugin-tap-as-a-service-yoga
@@ -271,3 +282,5 @@
     required-projects: *required-projects-yoga
     vars:
       network_api_extensions_common: *api_extensions
+      devstack_localrc:
+        NEUTRON_DEPLOY_MOD_WSGI: false
diff --git a/zuul.d/zed_jobs.yaml b/zuul.d/zed_jobs.yaml
index b002218..176527d 100644
--- a/zuul.d/zed_jobs.yaml
+++ b/zuul.d/zed_jobs.yaml
@@ -102,6 +102,7 @@
         - ipv6_metadata
       devstack_localrc:
         NETWORK_API_EXTENSIONS: "{{ (network_api_extensions_common + network_api_extensions_openvswitch) | join(',') }}"
+        NEUTRON_DEPLOY_MOD_WSGI: false
       devstack_local_conf:
         test-config:
           $TEMPEST_CONFIG:
@@ -137,6 +138,7 @@
       network_available_features: *available_features
       devstack_localrc:
         NETWORK_API_EXTENSIONS: "{{ (network_api_extensions_common + network_api_extensions_openvswitch) | join(',') }}"
+        NEUTRON_DEPLOY_MOD_WSGI: false
       devstack_local_conf:
         test-config:
           $TEMPEST_CONFIG:
@@ -208,6 +210,8 @@
     required-projects: *required-projects-zed
     vars:
       network_api_extensions_common: *api_extensions
+      devstack_localrc:
+        NEUTRON_DEPLOY_MOD_WSGI: false
 
 - job:
     name: neutron-tempest-plugin-designate-scenario-zed
@@ -226,6 +230,8 @@
     required-projects: *required-projects-zed
     vars:
       network_api_extensions_common: *api_extensions
+      devstack_localrc:
+        NEUTRON_DEPLOY_MOD_WSGI: false
 
 - job:
     name: neutron-tempest-plugin-bgpvpn-bagpipe-zed
@@ -235,6 +241,8 @@
     required-projects: *required-projects-zed
     vars:
       network_api_extensions: *api_extensions
+      devstack_localrc:
+        NEUTRON_DEPLOY_MOD_WSGI: false
 
 - job:
     name: neutron-tempest-plugin-dynamic-routing-zed
@@ -275,6 +283,8 @@
     required-projects: *required-projects-zed
     vars:
       network_api_extensions_common: *api_extensions
+      devstack_localrc:
+        NEUTRON_DEPLOY_MOD_WSGI: false
 
 - job:
     name: neutron-tempest-plugin-vpnaas-zed
@@ -284,6 +294,8 @@
     required-projects: *required-projects-zed
     vars:
       network_api_extensions_common: *api_extensions
+      devstack_localrc:
+        NEUTRON_DEPLOY_MOD_WSGI: false
 
 - job:
     name: neutron-tempest-plugin-tap-as-a-service-zed
@@ -293,3 +305,5 @@
     required-projects: *required-projects-zed
     vars:
       network_api_extensions_common: *api_extensions
+      devstack_localrc:
+        NEUTRON_DEPLOY_MOD_WSGI: false