Repository: sapcc/octavia-f5-provider-driver

Issue:
Add support for custom routes for LB listener port
A router has custom routes pointing to hosts in a private network, e.g.

```$ openstack router show dfa1ce43-eaf8-477a-bcd0-b98dba2cd892 -f json -c routes | jq -r
{
  "routes": [
    {
      "destination": "100.101.0.0/24",
      "nexthop": "10.180.142.85"
    },
    {
      "destination": "100.101.1.0/24",
      "nexthop": "10.180.142.12"
    }
  ]
}
```

If a loadbalancer has pool members from the `100.101.0.0/24` or `100.101.1.0/24` CIDRs, the traffic goes through the router.

Expected behavior: configure these routes directly on the loadbalancer to avoid superfluous traffic on the router.

cc @databus23 

Discussion:

Patch:
diff --git a/octavia_f5/common/constants.py b/octavia_f5/common/constants.py
index 9720e2b..4cea26a 100644
--- a/octavia_f5/common/constants.py
+++ b/octavia_f5/common/constants.py
@@ -26,7 +26,9 @@ PREFIX_HEALTH_MONITOR = 'hm_'
 PREFIX_LOADBALANCER = 'lb_'
 PREFIX_POLICY = 'l7policy_'
 PREFIX_WRAPPER_POLICY = 'wrapper_policy_'
+PREFIX_NETWORK_LEGACY = 'net-'
 PREFIX_NETWORK = 'net_'
+PREFIX_SUBNET = 'sub_'
 PREFIX_IRULE = 'irule_'
 PREFIX_MEMBER = 'member_'
 PREFIX_SECRET = 'secret_'
diff --git a/octavia_f5/controller/worker/controller_worker.py b/octavia_f5/controller/worker/controller_worker.py
index f5114fd..983b97a 100644
--- a/octavia_f5/controller/worker/controller_worker.py
+++ b/octavia_f5/controller/worker/controller_worker.py
@@ -107,6 +107,8 @@ class ControllerWorker(object):
         super(ControllerWorker, self).__init__()
 
     def as3worker(self):
+        """ AS3 Worker thread, pops tenant to refresh from thread-safe set queue"""
+
         @lockutils.synchronized("f5sync", fair=True)
         def f5sync(network_id, device, *args):
             self._metric_as3worker_queue.labels(octavia_host=CONF.host).set(self.queue.qsize())
@@ -127,8 +129,8 @@ class ControllerWorker(object):
                     self.l2sync.ensure_l2_flow(selfips, network_id, device)
                 elif any(lb.provisioning_status in [lib_consts.PENDING_CREATE, lib_consts.PENDING_DELETE]
                          for lb in loadbalancers):
-                    # Network already exists, just ensure correct selfips
-                    self.l2sync.sync_l2_selfips_flow(selfips, network_id, device)
+                    # Network already exists, just ensure correct selfips and subnet routes
+                    self.l2sync.sync_l2_selfips_and_subnet_routes_flow(selfips, network_id, device)
                 self.sync.tenant_update(network_id, device, selfips).raise_for_status()
 
             # update status of just-synced LBs
@@ -136,7 +138,7 @@ class ControllerWorker(object):
             for lb in loadbalancers:
                 self._reset_in_use_quota(lb.project_id)
 
-        """ AS3 Worker thread, pops tenant to refresh from thread-safe set queue"""
+        # run sync loop
         while True:
             try:
                 network_id, device = self.queue.get()
diff --git a/octavia_f5/controller/worker/flows/f5_flows.py b/octavia_f5/controller/worker/flows/f5_flows.py
index 3b02304..5b5218b 100644
--- a/octavia_f5/controller/worker/flows/f5_flows.py
+++ b/octavia_f5/controller/worker/flows/f5_flows.py
@@ -12,12 +12,15 @@
 #  License for the specific language governing permissions and limitations
 #  under the License.
 
+from oslo_log import log as logging
 from taskflow import flow
 from taskflow.patterns import unordered_flow, linear_flow
 
 from octavia.network import data_models as network_models
 from octavia_f5.controller.worker.tasks import f5_tasks
 
+LOG = logging.getLogger(__name__)
+
 
 class F5Flows(object):
     def ensure_l2(self, selfips: [network_models.Port]) -> flow.Flow:
@@ -29,14 +32,16 @@ class F5Flows(object):
             ensure_selfip_subflow.add(ensure_selfip)
 
         ensure_routedomain = f5_tasks.EnsureRouteDomain()
-        ensure_route = f5_tasks.EnsureRoute()
+        ensure_default_route = f5_tasks.EnsureDefaultRoute()
+        ensure_static_routes = f5_tasks.EnsureSubnetRoutes(inject={'selfips': selfips})
         ensure_vlan = f5_tasks.EnsureVLAN()
 
         ensure_l2_flow = linear_flow.Flow('ensure-l2-flow')
         ensure_l2_flow.add(ensure_vlan,
                            ensure_routedomain,
                            ensure_selfip_subflow,
-                           ensure_route)
+                           ensure_default_route,
+                           ensure_static_routes)
         return ensure_l2_flow
 
     def remove_l2(self, selfips: [str]) -> flow.Flow:
@@ -47,35 +52,66 @@ class F5Flows(object):
                                                    inject={'port': selfip})
             cleanup_selfip_subflow.add(cleanup_selfip)
 
-        cleanup_route = f5_tasks.CleanupRoute()
+        cleanup_subnet_routes = f5_tasks.CleanupSubnetRoutes(inject={'selfips': selfips,
+                                                                     'delete_all': True})
+        cleanup_route = f5_tasks.CleanupDefaultRoute()
         cleanup_routedomain = f5_tasks.CleanupRouteDomain()
         cleanup_vlan = f5_tasks.CleanupVLAN()
 
         cleanup_l2_flow = linear_flow.Flow('cleanup-l2-flow')
-        cleanup_l2_flow.add(cleanup_route,
+        cleanup_l2_flow.add(cleanup_subnet_routes,
+                            cleanup_route,
                             cleanup_selfip_subflow,
                             cleanup_routedomain,
                             cleanup_vlan)
         return cleanup_l2_flow
 
-    def sync_l2_selfips(self, selfips: dict) -> flow.Flow:
-        """ Updates SelfIPs of a specific network
-            * Adding new ones
-            * Removing orphaned ones
+    def cleanup_selfips_and_subnet_routes(self, expected_selfips, device_selfips, store: dict) -> flow.Flow:
+        """ Remove unneeded SelfIPs and subnet routes of a specific network
 
-        :param selfips: 'add' and 'remove' dictionary of selfips
+        :param expected_selfips: SelfIPs that must exist
+        :param device_selfips: SelfIPs that currently exist
         """
 
-        sync_l2_selfips_flow = unordered_flow.Flow('sync-l2-selfips-flow')
-        for selfip in selfips.get('add', []):
-            ensure_selfip = f5_tasks.EnsureSelfIP(name=f"ensure-selfip-{selfip.id}", inject={'port': selfip})
-            sync_l2_selfips_flow.add(ensure_selfip)
+        cleanup_selfips_and_subnet_routes_flow = unordered_flow.Flow('cleanup-selfips-and-subnet-routes-flow')
 
-        for selfip in selfips.get('remove', []):
+        # removal of SelfIPs
+        selfips_to_remove = [port for port in device_selfips if port.id not in [p.id for p in expected_selfips]]
+        LOG.debug("%s: SelfIPs to remove for network %s: %s",
+                  store['bigip'].hostname, store['network'].id, [p.id for p in selfips_to_remove])
+        for selfip in selfips_to_remove:
             cleanup_selfip = f5_tasks.RemoveSelfIP(name=f"cleanup-selfip-{selfip.id}", inject={'port': selfip})
-            sync_l2_selfips_flow.add(cleanup_selfip)
+            cleanup_selfips_and_subnet_routes_flow.add(cleanup_selfip)
+
+        # removal of subnet routes
+        cleanup_selfips_and_subnet_routes_flow.add(
+            f5_tasks.CleanupSubnetRoutes(inject={'selfips': expected_selfips}))
+
+        return cleanup_selfips_and_subnet_routes_flow
+
+    def ensure_selfips_and_subnet_routes(self, expected_selfips, device_selfips, store: dict) -> flow.Flow:
+        """ Add needed SelfIPs and subnet routes of a specific network
+
+        :param expected_selfips: SelfIPs that must exist
+        :param device_selfips: SelfIPs that currently exist
+        """
+
+        ensure_selfips_and_subnet_routes_flow = unordered_flow.Flow('ensure-selfips-and-subnet-routes-flow')
+
+        # adding SelfIPs
+        selfips_to_add = [port for port in expected_selfips if port.id not in [p.id for p in device_selfips]]
+        LOG.debug("%s: SelfIPs to add for network %s: %s",
+                  store['bigip'].hostname, store['network'].id, [p.id for p in selfips_to_add])
+
+        for selfip in selfips_to_add:
+            ensure_selfip = f5_tasks.EnsureSelfIP(name=f"ensure-selfip-{selfip.id}", inject={'port': selfip})
+            ensure_selfips_and_subnet_routes_flow.add(ensure_selfip)
+
+        # removal of subnet routes
+        ensure_selfips_and_subnet_routes_flow.add(
+            f5_tasks.EnsureSubnetRoutes(inject={'selfips': expected_selfips}))
 
-        return sync_l2_selfips_flow
+        return ensure_selfips_and_subnet_routes_flow
 
     def get_selfips_from_device_for_vlan(self) -> [network_models.Port]:
         return f5_tasks.GetAllSelfIPsForVLAN(name='all-selfips')
diff --git a/octavia_f5/controller/worker/l2_sync_manager.py b/octavia_f5/controller/worker/l2_sync_manager.py
index 8fc2d71..d7720aa 100644
--- a/octavia_f5/controller/worker/l2_sync_manager.py
+++ b/octavia_f5/controller/worker/l2_sync_manager.py
@@ -25,7 +25,9 @@ from octavia.common import data_models as octavia_models
 from octavia.common.base_taskflow import BaseTaskFlowEngine
 from octavia.network import base
 from octavia.network import data_models as network_models
+from octavia_f5.common import constants
 from octavia_f5.controller.worker.flows import f5_flows
+from octavia_f5.controller.worker.tasks import f5_tasks
 from octavia_f5.restclient.bigip import bigip_auth
 from octavia_f5.restclient.bigip.bigip_restclient import BigIPRestClient
 from octavia_f5.utils import driver_utils, decorators
@@ -102,22 +104,31 @@ class L2SyncManager(BaseTaskFlowEngine):
         with tf_logging.LoggingListener(e, log=LOG):
             e.run()
 
-    def _do_sync_l2_selfips_flow(self, expected_selfips: [network_models.Port], store: dict):
+    def _do_sync_l2_selfips_and_subnet_routes_flow(self, expected_selfips: [network_models.Port], store: dict):
+        """Remove unneeded SelfIPs and subnet routes, then add missing SelfIPs and subnet routes.
+
+        If in another subnet of this network a load balancer is created or deleted, either a subnet route has to be
+        replaced with a SelfIP or vice versa. Since SelfIPs and subnet routes conflict, deletion must always happen
+        before creation."""
+
         e = self.taskflow_load(self._f5flows.get_selfips_from_device_for_vlan(), store=store)
         with tf_logging.LoggingListener(e, log=LOG):
             e.run()
 
+        LOG.debug("%s: The expected SelfIPs for network %s are: %s",
+                  store['bigip'].hostname, store['network'].id, [sip.id for sip in expected_selfips])
+
         device_selfips = e.storage.get('all-selfips')
-        selfips = {
-            'add': [port for port in expected_selfips if port.id not in [p.id for p in device_selfips]],
-            'remove': [port for port in device_selfips if port.id not in [p.id for p in expected_selfips]]
-        }
 
-        LOG.debug("%s: L2 SelfIP sync diff for network %s: %s",
-                  store['bigip'].hostname, store['network'].id, {'add': [p.id for p in selfips['add']],
-                                                                 'remove': [p.id for p in selfips['remove']]})
+        # remove unneeded SelfIPs/subnet routes
+        e = self.taskflow_load(self._f5flows.cleanup_selfips_and_subnet_routes(
+            expected_selfips, device_selfips, store=store), store=store)
+        with tf_logging.LoggingListener(e, log=LOG):
+            e.run()
 
-        e = self.taskflow_load(self._f5flows.sync_l2_selfips(selfips), store=store)
+        # add missing SelfIPs/subnet routes
+        e = self.taskflow_load(self._f5flows.ensure_selfips_and_subnet_routes(
+            expected_selfips, device_selfips, store=store), store=store)
         with tf_logging.LoggingListener(e, log=LOG):
             e.run()
 
@@ -221,19 +232,18 @@ class L2SyncManager(BaseTaskFlowEngine):
                 self._metric_failed_futures.labels(bigip.hostname, 'remove_l2_flow').inc()
                 LOG.error("Failed running remove_l2_flow for host %s: %s", bigip.hostname, e)
 
-    def sync_l2_selfips_flow(self, selfips: [network_models.Port], network_id: str, device=None):
-        """ Runs the taskflows to sync selfips (add/remove) on all bigip devices in parallel
+    def sync_l2_selfips_and_subnet_routes_flow(self, selfips: [network_models.Port], network_id: str, device=None):
+        """ Runs the taskflows to sync (add/remove) SelfIPs and subnet routes on all bigip devices in parallel
 
         :param selfips: Neutron SelfIP ports expected
         :param network_id: Neutron Network ID
         :param device: optional device host to sync, defaults to all devices to sync
         """
-        if not selfips:
-            return
 
         network = self._network_driver.get_network(network_id)
         if not network.has_bound_segment():
-            raise Exception(f"Failed sync_l2_selfips_flow for network_id={network_id}: No segment bound")
+            raise Exception(
+                f"Failed sync_l2_selfips_and_subnet_routes_flow for network_id={network_id}: No segment bound")
 
         fs = {}
         for bigip in self._bigips:
@@ -242,9 +252,8 @@ class L2SyncManager(BaseTaskFlowEngine):
 
             store = {'bigip': bigip, 'network': network}
             selfips_for_host = [selfip for selfip in selfips if bigip.hostname in selfip.name]
-            fs[self.executor.submit(self._do_sync_l2_selfips_flow,
-                                    expected_selfips=selfips_for_host,
-                                    store=store)] = bigip
+            fs[self.executor.submit(self._do_sync_l2_selfips_and_subnet_routes_flow,
+                                    expected_selfips=selfips_for_host, store=store)] = bigip
 
         done, not_done = futures.wait(fs, timeout=10)
         for f in done | not_done:
@@ -252,8 +261,8 @@ class L2SyncManager(BaseTaskFlowEngine):
             try:
                 f.result(0)
             except Exception as e:
-                self._metric_failed_futures.labels(bigip.hostname, 'sync_l2_selfips_flow').inc()
-                LOG.error("Failed running sync_l2_selfips_flow for host %s: %s", bigip.hostname, e)
+                self._metric_failed_futures.labels(bigip.hostname, 'sync_l2_selfips_and_subnet_routes_flow').inc()
+                LOG.error("Failed running sync_l2_selfips_and_subnet_routes_flow for host %s: %s", bigip.hostname, e)
 
     @decorators.RaisesIControlRestError()
     def full_sync(self, loadbalancers: [octavia_models.LoadBalancer]):
@@ -286,7 +295,19 @@ class L2SyncManager(BaseTaskFlowEngine):
                     continue
 
                 # Skip unmanaged routes
-                if not (route['name'].startswith('net-') or route['name'].startswith('vlan-')):
+                if not (route['name'].startswith(constants.PREFIX_NETWORK_LEGACY)
+                        or route['name'].startswith('vlan-')
+                        or route['name'].startswith(constants.PREFIX_NETWORK)):
+                    continue
+
+                # Skip needed subnet routes, i.e. routes for subnets that don't have LBs but are in a network with LBs
+                lb_subnets = [lb.vip.subnet_id for lb in loadbalancers]
+                needed_subnet_routes = []
+                for net, subs in [(n, networks[n].subnets) for n in networks]:
+                    # lb_subnets is the list of subnets that don't have subnet routes but SelfIPs
+                    needed_subnet_routes.extend([f5_tasks.get_subnet_route_name(net, sub)
+                                                 for sub in subs if sub not in lb_subnets])
+                if route['name'] in needed_subnet_routes:
                     continue
 
                 # Cleanup
diff --git a/octavia_f5/controller/worker/tasks/f5_tasks.py b/octavia_f5/controller/worker/tasks/f5_tasks.py
index 30d021c..57370a8 100644
--- a/octavia_f5/controller/worker/tasks/f5_tasks.py
+++ b/octavia_f5/controller/worker/tasks/f5_tasks.py
@@ -19,6 +19,7 @@ from oslo_log import log as logging
 from taskflow import task
 
 from octavia.network import data_models as network_models
+from octavia_f5.common import constants
 from octavia_f5.network import data_models as f5_network_models
 from octavia_f5.restclient.bigip import bigip_restclient
 from octavia_f5.utils import driver_utils, decorators
@@ -27,6 +28,18 @@ LOG = logging.getLogger(__name__)
 CONF = cfg.CONF
 
 
+def subnet_in_selfips(subnet, selfips):
+    for selfip in selfips:
+        for fixed_ip in selfip.fixed_ips:
+            if fixed_ip.subnet_id == subnet:
+                return True
+    return False
+
+def get_subnet_route_name(network_id, subnet_id):
+    return "{}{}_{}{}".format(constants.PREFIX_NETWORK, network_id,
+                              constants.PREFIX_SUBNET, subnet_id)
+
+
 class EnsureVLAN(task.Task):
     default_provides = 'device_vlan'
 
@@ -225,7 +238,7 @@ class GetAllSelfIPsForVLAN(task.Task):
                 and item['name'].startswith('port-')]
 
 
-class EnsureRoute(task.Task):
+class EnsureDefaultRoute(task.Task):
     default_provides = 'device_route'
 
     """ Task to create or update Route if needed """
@@ -272,10 +285,61 @@ class EnsureRoute(task.Task):
         return device_route
 
 
+class EnsureSubnetRoutes(task.Task):
+    """ Task to create missing needed static subnet routes"""
+
+    @decorators.RaisesIControlRestError()
+    def execute(self, bigip: bigip_restclient.BigIPRestClient,
+                selfips: [network_models.Port],
+                network: f5_network_models.Network):
+
+        # Skip passive device if route_on_active is enabled
+        if CONF.networking.route_on_active and not bigip.is_active:
+            return None
+
+        # Fetch existing routes
+        response = bigip.get(path=f"/mgmt/tm/net/route?$filter=partition+eq+Common").json()
+        existing_routes = response.get('items', [])
+
+        # subnet routes that must exist (routes already exist for SelfIPs)
+        subnets_that_need_routes = [subnet for subnet in network.subnets if not subnet_in_selfips(subnet, selfips)]
+
+        # common prefix for subnet routes of this network
+        subnet_route_network_part = get_subnet_route_name(network.id, '')
+
+        # delete existing subnet routes that aren't needed anymore - we'll only provision the missing ones
+        for existing_route in existing_routes:
+            existing_route_name = existing_route['name']
+
+            # ignore routes that are not subnet routes of this network
+            if not existing_route_name.startswith(subnet_route_network_part):
+                continue
+
+            existing_route_subnet = existing_route_name[len(subnet_route_network_part):]
+            if existing_route_subnet in subnets_that_need_routes:
+                # if the subnet route is a needed one there's no need to provision it again
+                subnets_that_need_routes.remove(existing_route_subnet)
+
+        # Add missing subnet routes
+        network_driver = driver_utils.get_network_driver()
+        for subnet_id in subnets_that_need_routes:
+            cidr = IPNetwork(network_driver.get_subnet(subnet_id).cidr)
+
+            name = get_subnet_route_name(network.id, subnet_id)
+            vlan = f"/Common/vlan-{network.vlan_id}"
+            net = f"{cidr.ip}%{network.vlan_id}/{cidr.prefixlen}"
+            route = {'name': name, 'tmInterface': vlan, 'network': net}
+
+            res = bigip.post(path='/mgmt/tm/net/route', json=route)
+            res.raise_for_status()
+
+
 """ Cleanup Tasks """
 
 
-class CleanupRoute(task.Task):
+class CleanupDefaultRoute(task.Task):
+
+    @decorators.RaisesIControlRestError()
     def execute(self, network: f5_network_models.Network,
                 bigip: bigip_restclient.BigIPRestClient):
 
@@ -297,6 +361,42 @@ class CleanupRoute(task.Task):
                         bigip.hostname, network.id, network.vlan_id, res.content)
 
 
+class CleanupSubnetRoutes(task.Task):
+    """Task to clean up static subnet routes. If the network is to be deleted, set delete_all=True.
+    Else only the unneeded subnet routes of this network are deleted."""
+
+    @decorators.RaisesIControlRestError()
+    def execute(self, bigip: bigip_restclient.BigIPRestClient,
+                selfips: [network_models.Port],
+                network: f5_network_models.Network,
+                delete_all=False):
+
+        subnets_that_need_routes = []
+        if not delete_all:
+            subnets_that_need_routes = [subnet for subnet in network.subnets if not subnet_in_selfips(subnet, selfips)]
+
+        # prefix of subnet routes that belong to this network
+        subnet_route_network_part = get_subnet_route_name(network.id, '')
+
+        # Fetch existing routes in the partition
+        response = bigip.get(path=f"/mgmt/tm/net/route").json()
+        existing_routes = response.get('items', [])
+
+        # delete routes from this network
+        for existing_route in existing_routes:
+            existing_route_name = existing_route['name']
+
+            # ignore routes that are not subnet routes of this network
+            if not existing_route_name.startswith(subnet_route_network_part):
+                continue
+
+            # delete subnet route if it's not needed
+            existing_route_subnet = existing_route_name[len(subnet_route_network_part):]
+            if existing_route_subnet not in subnets_that_need_routes:
+                res = bigip.delete(path=f"/mgmt/tm/net/route/~Common~{existing_route_name}")
+                res.raise_for_status()
+
+
 class RemoveSelfIP(task.Task):
     def execute(self, port: network_models.Port,
                 bigip: bigip_restclient.BigIPRestClient):


Test patch:
diff --git a/octavia_f5/tests/unit/controller/worker/flows/test_f5_flows.py b/octavia_f5/tests/unit/controller/worker/flows/test_f5_flows.py
index 5b7d76c..e41525f 100644
--- a/octavia_f5/tests/unit/controller/worker/flows/test_f5_flows.py
+++ b/octavia_f5/tests/unit/controller/worker/flows/test_f5_flows.py
@@ -81,7 +81,10 @@ class TestF5Flows(base.TestCase):
         )
 
         mock_bigip = mock.Mock(spec=as3restclient.AS3RestClient)
-        mock_bigip.get.side_effect = empty_response
+        mock_bigip.get.side_effect = [empty_response(), empty_response(),
+                                      empty_response(), empty_response(),
+                                      empty_response(), empty_response(),
+                                      MockResponse({'items':[]}, status_code=200)]
         f5flows = f5_flows.F5Flows()
 
         engines.run(f5flows.ensure_l2([selfip_port]),
@@ -156,7 +159,8 @@ class TestF5Flows(base.TestCase):
         mock_bigip.get.side_effect = [mock_vlan_response,
                                       mock_routedomain_response,
                                       mock_selfip_response,
-                                      mock_route_response]
+                                      mock_route_response,
+                                      MockResponse({'items':[]}, status_code=200)]
         f5flows = f5_flows.F5Flows()
 
         engines.run(f5flows.ensure_l2([selfip_port]),
diff --git a/octavia_f5/tests/unit/controller/worker/tasks/test_f5_tasks.py b/octavia_f5/tests/unit/controller/worker/tasks/test_f5_tasks.py
index 54e1c36..4a020d3 100644
--- a/octavia_f5/tests/unit/controller/worker/tasks/test_f5_tasks.py
+++ b/octavia_f5/tests/unit/controller/worker/tasks/test_f5_tasks.py
@@ -17,6 +17,7 @@ from unittest import mock
 from oslo_config import cfg
 from oslo_config import fixture as oslo_fixture
 from oslo_log import log as logging
+from oslo_utils import uuidutils
 from taskflow import engines
 
 import octavia.tests.unit.base as base
@@ -62,7 +63,7 @@ class TestF5Tasks(base.TestCase):
         mock_bigip = mock.Mock(spec=as3restclient.AS3RestClient)
         mock_bigip.get.return_value = mock_route_response
 
-        engines.run(f5_tasks.EnsureRoute(),
+        engines.run(f5_tasks.EnsureDefaultRoute(),
                     store={'network': mock_network,
                            'bigip': mock_bigip,
                            'subnet_id': 'test-subnet-id'})
@@ -96,7 +97,7 @@ class TestF5Tasks(base.TestCase):
         mock_bigip.get.side_effect = [test_f5_flows.MockResponse({}, 404),
                                       mock_route_response]
 
-        engines.run(f5_tasks.EnsureRoute(),
+        engines.run(f5_tasks.EnsureDefaultRoute(),
                     store={'network': mock_network,
                            'bigip': mock_bigip,
                            'subnet_id': 'test-subnet-id'})
@@ -134,7 +135,7 @@ class TestF5Tasks(base.TestCase):
         # Patch should fail
         mock_bigip.patch.side_effect = test_f5_flows.empty_response
 
-        engines.run(f5_tasks.EnsureRoute(),
+        engines.run(f5_tasks.EnsureDefaultRoute(),
                     store={'network': mock_network,
                            'bigip': mock_bigip,
                            'subnet_id': 'test-subnet-id'})
@@ -150,3 +151,81 @@ class TestF5Tasks(base.TestCase):
         mock_bigip.post.assert_called_with(json={
             'name': 'vlan-1234', 'gw': '8.8.8.8%1234', 'network': 'default%1234'},
             path='/mgmt/tm/net/route')
+
+    @mock.patch("octavia.network.drivers.noop_driver.driver.NoopManager"
+                ".get_subnet")
+    def test_SyncSubnetRoutes(self, mock_get_subnet):
+        mock_subnets = [
+            network_models.Subnet(
+                id=uuidutils.generate_uuid(), gateway_ip='2.3.4.5',
+                cidr='2.3.4.0/24', network_id='test-network-id'),
+            network_models.Subnet(
+                id=uuidutils.generate_uuid(), gateway_ip='10.0.0.1',
+                cidr='10.0.0.0/24', network_id='test-network-id'),
+        ]
+        mock_get_subnet.side_effect = mock_subnets
+        mock_network = f5_network_models.Network(
+            mtu=9000, id=uuidutils.generate_uuid(),
+            subnets=[subnet.id for subnet in mock_subnets],
+            segments=[{'provider:physical_network': 'physnet',
+                       'provider:segmentation_id': 1234}]
+        )
+
+        # Check that subnet route names always include the network ID as well as the subnet ID
+        subnet_route_name = f5_tasks.get_subnet_route_name(mock_network.id, mock_subnets[0].id)
+        self.assertTrue(mock_network.id in subnet_route_name
+                        or mock_network.id.replace('-', '_') in subnet_route_name)
+        self.assertTrue(mock_subnets[0].id in subnet_route_name
+                        or mock_subnets[0].id.replace('-', '_') in subnet_route_name)
+
+        # No subnet route shall be created when every subnet already has either a SelfIP or a subnet route
+        mock_route_response = test_f5_flows.MockResponse({
+            'items': [
+                {
+                    'name': f5_tasks.get_subnet_route_name(mock_network.id, mock_subnets[1].id),
+                    'tmInterface': 'vlan-1234',
+                    'network': '10.0.0.2%1234/24'
+                }
+            ]
+        }, status_code=200)
+
+        mock_bigip = mock.Mock(spec=as3restclient.AS3RestClient)
+        mock_bigip.get.return_value = mock_route_response
+        mock_selfip = network_models.Port(
+            name=f"local-bigipmockhost-test-subnet-id",
+            fixed_ips=[network_models.FixedIP(
+                ip_address='2.3.4.255',
+                subnet_id=mock_subnets[0].id)
+            ]
+        )
+
+        engines.run(f5_tasks.EnsureSubnetRoutes(),
+                    store={'network': mock_network,
+                           'bigip': mock_bigip,
+                           'selfips': [mock_selfip]})
+
+        mock_bigip.get.assert_called_with(
+            path=f"/mgmt/tm/net/route?$filter=partition+eq+Common")
+        mock_bigip.post.assert_not_called()
+        mock_bigip.delete.assert_not_called()
+
+        # Check creating new route
+        mock_bigip = mock.Mock(spec=as3restclient.AS3RestClient)
+        mock_route_response = test_f5_flows.MockResponse({'items': []}, status_code=200)
+        mock_bigip.get.return_value = mock_route_response
+        engines.run(f5_tasks.EnsureSubnetRoutes(),
+                    store={'network': mock_network,
+                           'bigip': mock_bigip,
+                           'selfips': [mock_selfip]})
+
+        mock_bigip.get.assert_called_with(
+            path=f"/mgmt/tm/net/route?$filter=partition+eq+Common")
+        mock_bigip.delete.assert_not_called()
+        mock_bigip.post.assert_called_with(
+            path='/mgmt/tm/net/route',
+            json={
+                'name': f5_tasks.get_subnet_route_name(mock_network.id, mock_subnets[1].id),
+                'tmInterface': '/Common/vlan-1234',
+                'network': '2.3.4.0%1234/24'
+            }
+        )