For a distributed load balancer, emit one Advertised_Route per
(VIP IP, backend LSP). ip_port_mappings supplies the backend LSP used as
tracked_port. The existing SB index deduplicates listeners that share the
same VIP IP and backend LSP (e.g. two Services on different ports fronting
the same pod).

Skip backends without an ip_port_mappings entry. If no backend has a
mapping, emit one route for the VIP with the peer LRP as tracked_port.
Centralized load balancers continue to use that VIP-level representation.
Both modes retain one forwarding parsed_route per VIP on the advertising
router.

Process load balancers attached directly to the neighbouring router and
through its load balancer groups. Avoid duplicate work when the same load
balancer appears in both places. LBs attached directly to the advertising
LR use the same per-backend path. Cover an association-only update to
ensure the lr_stateful dependency rebuilds dynamic routes for the affected
router.

Set external_ids:source=lb on LB routes. Distributed routes also carry
external_ids:distributed-lb=true. Patch 5 uses these keys for
controller health gating. Patch 6 uses them for status reporting.

Using the backend LSP as tracked_port makes
dynamic-routing-redistribute-local-only install the route only on chassis
that host a backend for the VIP.

When a route becomes visible before the route node has recorded its newly
local datapath, use runtime_data to recognize it and request recomputation.
Otherwise the new route can remain absent until another relevant change.

The local-only system test exercises an existing ct_lb encoding path
with no active local backend. Destroy the temporary action string
before that path returns.

Document the external_ids:source key.

Signed-off-by: Dmitrii Shcherbakov <[email protected]>
---
 controller/ovn-controller.c       |  10 +-
 lib/actions.c                     |   1 +
 lib/ovn-util.h                    |   3 +
 northd/en-advertised-route-sync.c | 286 ++++++++++++----
 ovn-sb.xml                        |  19 +-
 tests/ovn-northd.at               | 545 +++++++++++++++++++++++++++++-
 tests/system-ovn.at               | 140 ++++++++
 7 files changed, 935 insertions(+), 69 deletions(-)

diff --git a/controller/ovn-controller.c b/controller/ovn-controller.c
index 61538e3d8..141485237 100644
--- a/controller/ovn-controller.c
+++ b/controller/ovn-controller.c
@@ -5541,6 +5541,8 @@ static enum engine_input_handler_result
 route_sb_advertised_route_data_handler(struct engine_node *node, void *data)
 {
     struct ed_type_route *re_data = data;
+    struct ed_type_runtime_data *rt_data =
+        engine_get_input_data("runtime_data", node);
     const struct sbrec_advertised_route_table *advertised_route_table =
         EN_OVSDB_GET(engine_get_input("SB_advertised_route", node));
 
@@ -5560,7 +5562,13 @@ route_sb_advertised_route_data_handler(struct 
engine_node *node, void *data)
             tracked_datapath_find(&re_data->tracked_route_datapaths,
                                   sbrec_route->datapath);
         if (!re_t_dp) {
-            continue;
+            /* Monitor conditions can lag a newly-local datapath.  Check
+             * runtime_data while its route rows become visible. */
+            struct local_datapath *ld = get_local_datapath(
+                &rt_data->local_datapaths, sbrec_route->datapath->tunnel_key);
+            if (!ld || ld->is_switch) {
+                continue;
+            }
         }
 
         if (sbrec_advertised_route_is_new(sbrec_route) ||
diff --git a/lib/actions.c b/lib/actions.c
index 93d7aefd2..4b5fda856 100644
--- a/lib/actions.c
+++ b/lib/actions.c
@@ -1523,6 +1523,7 @@ encode_ct_lb(const struct ovnact_ct_lb *cl,
     }
 
     if (!n_active_backends) {
+        ds_destroy(&ds);
         return;
     }
 
diff --git a/lib/ovn-util.h b/lib/ovn-util.h
index 29f6bb739..4209ab07f 100644
--- a/lib/ovn-util.h
+++ b/lib/ovn-util.h
@@ -36,6 +36,9 @@
 #define ROUTE_ORIGIN_CONNECTED_DYNAMIC "connected-dynamic"
 #define ROUTE_OVERRIDE_CONNECTED "override-connected"
 
+#define OVN_AR_SOURCE_ID "source"
+#define OVN_AR_DISTRIBUTED_LB_ID "distributed-lb"
+
 #define ETH_CRC_LENGTH 4
 #define ETHERNET_OVERHEAD (ETH_HEADER_LEN + ETH_CRC_LENGTH)
 
diff --git a/northd/en-advertised-route-sync.c 
b/northd/en-advertised-route-sync.c
index 2d38754b1..ed8f61e3c 100644
--- a/northd/en-advertised-route-sync.c
+++ b/northd/en-advertised-route-sync.c
@@ -23,7 +23,11 @@
 #include "en-lr-stateful.h"
 #include "lb.h"
 #include "openvswitch/hmap.h"
+#include "openvswitch/vlog.h"
 #include "ovn-util.h"
+#include "util.h"
+
+VLOG_DEFINE_THIS_MODULE(en_advertised_route_sync);
 
 struct ar_entry {
     struct hmap_node hmap_node;
@@ -37,6 +41,7 @@ struct ar_entry {
                                           * advertises this route with a
                                           * higher priority. */
     enum route_source source;
+    bool distributed_lb;
 };
 
 /* Add a new entries to the to-be-advertised routes.
@@ -57,6 +62,9 @@ ar_entry_add_nocopy(struct hmap *routes, const struct 
ovn_datapath *od,
     uint32_t hash = uuid_hash(&od->sdp->sb_dp->header_.uuid);
     hash = hash_string(op->sb->logical_port, hash);
     hash = hash_string(ip_prefix, hash);
+    if (tracked_port) {
+        hash = hash_string(tracked_port->sb->logical_port, hash);
+    }
     hmap_insert(routes, &route_e->hmap_node, hash);
 
     return route_e;
@@ -87,6 +95,9 @@ ar_entry_find(struct hmap *route_map,
     hash = uuid_hash(&sb_db->header_.uuid);
     hash = hash_string(logical_port->logical_port, hash);
     hash = hash_string(ip_prefix, hash);
+    if (tracked_port) {
+        hash = hash_string(tracked_port->logical_port, hash);
+    }
 
     HMAP_FOR_EACH_WITH_HASH (route_e, hmap_node, hash, route_map) {
         if (!uuid_equals(&sb_db->header_.uuid,
@@ -106,6 +117,8 @@ ar_entry_find(struct hmap *route_map,
                     tracked_port != route_e->tracked_port->sb) {
                 continue;
             }
+        } else if (route_e->tracked_port) {
+            continue;
         }
 
         return route_e;
@@ -121,6 +134,37 @@ ar_entry_free(struct ar_entry *route_e)
     free(route_e);
 }
 
+static void
+ar_entry_merge_metadata(struct ar_entry *dst, const struct ar_entry *src)
+{
+    dst->distributed_lb |= src->distributed_lb;
+}
+
+static void
+ar_entry_sync_external_ids(const struct sbrec_advertised_route *sb_route,
+                           const struct ar_entry *route_e)
+{
+    struct smap ids;
+    smap_clone(&ids, &sb_route->external_ids);
+
+    if (route_e->source == ROUTE_SOURCE_LB) {
+        smap_replace(&ids, OVN_AR_SOURCE_ID, "lb");
+    } else {
+        smap_remove(&ids, OVN_AR_SOURCE_ID);
+    }
+
+    if (route_e->distributed_lb) {
+        smap_replace(&ids, OVN_AR_DISTRIBUTED_LB_ID, "true");
+    } else {
+        smap_remove(&ids, OVN_AR_DISTRIBUTED_LB_ID);
+    }
+
+    if (!smap_equal(&ids, &sb_route->external_ids)) {
+        sbrec_advertised_route_set_external_ids(sb_route, &ids);
+    }
+    smap_destroy(&ids);
+}
+
 static void
 advertised_route_table_sync(
     struct ovsdb_idl_txn *ovnsb_txn,
@@ -203,6 +247,13 @@ add_redistribute_parsed_route(struct hmap 
*parsed_routes_out,
     /* Parse the prefix (the VIP/FIP). */
     struct in6_addr prefix;
     if (!ip46_parse(ip_address, &prefix)) {
+        static struct vlog_rate_limit rl = VLOG_RATE_LIMIT_INIT(5, 1);
+        VLOG_WARN_RL(&rl, "Failed to parse IP address '%s' for %s "
+                     "redistribute forwarding route on datapath %s",
+                     ip_address,
+                     source == ROUTE_SOURCE_LB ? "LB" : "NAT",
+                     advertising_od->nbr ? advertising_od->nbr->name
+                                         : "<unknown>");
         return;
     }
     bool is_v6 = !IN6_IS_ADDR_V4MAPPED(&prefix);
@@ -216,6 +267,13 @@ add_redistribute_parsed_route(struct hmap 
*parsed_routes_out,
         nexthop_s = tracked_port->lrp_networks.ipv6_addrs[0].addr_s;
     }
     if (!nexthop_s) {
+        static struct vlog_rate_limit rl = VLOG_RATE_LIMIT_INIT(5, 1);
+        VLOG_WARN_RL(&rl, "No %s address on tracked port %s for %s "
+                     "redistribute forwarding route (prefix %s)",
+                     is_v6 ? "IPv6" : "IPv4",
+                     tracked_port->key,
+                     source == ROUTE_SOURCE_LB ? "LB" : "NAT",
+                     ip_address);
         return;
     }
 
@@ -417,39 +475,141 @@ build_nat_connected_routes(
     }
 }
 
-/* This function adds a new route for each IP in lb_ips to "routes".
- * If parsed_routes_out is non-NULL, also installs a local forwarding
- * parsed_route per VIP. */
+/* For a distributed nbrec_lb, emit one Advertised_Route per (VIP IP,
+ * backend LSP) pair.  A centralized LB uses one VIP-level route.  Both modes
+ * emit one forwarding parsed_route per VIP.
+ * Multiple listeners sharing the same VIP IP and backend LSP are
+ * deduplicated by ar_entry_find on the (datapath, logical_port,
+ * ip_prefix, tracked_port) key. When no backend has an ip_port_mappings
+ * entry, one Advertised_Route covers the VIP with fallback_tracked_port
+ * in place of a per-backend LSP. The forwarding route is emitted once
+ * per VIP regardless of backend count: the data-plane forwarding
+ * decision is independent of which backend ends up serving the flow. */
 static void
-build_lb_route_for_port(const struct ovn_port *advertising_op,
-                        const struct ovn_port *tracked_port,
-                        const struct ovn_lb_ip_set *lb_ips,
-                        struct hmap *routes,
-                        struct hmap *parsed_routes_out)
+build_lb_routes_for_lb(const struct ovn_port *advertising_op,
+                       const struct ovn_port *fallback_tracked_port,
+                       const struct nbrec_load_balancer *nbrec_lb,
+                       const struct hmap *lb_datapaths_map,
+                       const struct hmap *ls_ports,
+                       struct hmap *routes,
+                       struct hmap *parsed_routes_out)
 {
     const struct ovn_datapath *advertising_od = advertising_op->od;
 
-    const char *ip_address;
-    SSET_FOR_EACH (ip_address, &lb_ips->ips_v4_adv) {
-        ar_entry_add(routes, advertising_od, advertising_op,
-                     ip_address, tracked_port, ROUTE_SOURCE_LB);
+    if (!smap_get_bool(&nbrec_lb->options,
+                       "dynamic-routing-advertise", true)) {
+        return;
+    }
+
+    const struct uuid *lb_uuid = &nbrec_lb->header_.uuid;
+    const struct ovn_lb_datapaths *lb_dps =
+        ovn_lb_datapaths_find(lb_datapaths_map, lb_uuid);
+    if (!lb_dps) {
+        return;
+    }
+
+    const struct ovn_northd_lb *lb = lb_dps->lb;
+    for (size_t v = 0; v < lb->n_vips; v++) {
+        const struct ovn_lb_vip *vip = &lb->vips[v];
+        const struct ovn_northd_lb_vip *vip_nb = &lb->vips_nb[v];
+
         if (parsed_routes_out) {
-            add_redistribute_parsed_route(parsed_routes_out, advertising_od,
-                                          advertising_op, tracked_port,
-                                          ip_address, ROUTE_SOURCE_LB,
-                                          &advertising_op->nbrp->header_);
+            add_redistribute_parsed_route(
+                parsed_routes_out, advertising_od, advertising_op,
+                fallback_tracked_port, vip->vip_str, ROUTE_SOURCE_LB,
+                &nbrec_lb->header_);
+        }
+
+        if (!lb->is_distributed) {
+            if (!ar_entry_find(routes, advertising_od->sdp->sb_dp,
+                               advertising_op->sb, vip->vip_str,
+                               fallback_tracked_port
+                               ? fallback_tracked_port->sb : NULL)) {
+                ar_entry_add(routes, advertising_od, advertising_op,
+                             vip->vip_str, fallback_tracked_port,
+                             ROUTE_SOURCE_LB);
+            }
+            continue;
+        }
+
+        bool emitted_any = false;
+        for (size_t b = 0; b < vip_nb->n_backends; b++) {
+            const char *lsp_name = vip_nb->backends_nb[b].logical_port;
+            if (!lsp_name) {
+                continue;
+            }
+            const struct ovn_port *backend_op =
+                ovn_port_find(ls_ports, lsp_name);
+            if (!backend_op) {
+                continue;
+            }
+            /* The SB unique index on (datapath, logical_port,
+             * ip_prefix, tracked_port) means only one route per
+             * (VIP IP, backend LSP) pair can exist. */
+            struct ar_entry *route_e = ar_entry_find(
+                routes, advertising_od->sdp->sb_dp, advertising_op->sb,
+                vip->vip_str, backend_op->sb);
+            if (!route_e) {
+                route_e = ar_entry_add(routes, advertising_od,
+                                       advertising_op, vip->vip_str,
+                                       backend_op, ROUTE_SOURCE_LB);
+            }
+            route_e->distributed_lb = true;
+            emitted_any = true;
+        }
+        if (!emitted_any) {
+            struct ar_entry *route_e = ar_entry_find(
+                routes, advertising_od->sdp->sb_dp, advertising_op->sb,
+                vip->vip_str,
+                fallback_tracked_port ? fallback_tracked_port->sb : NULL);
+            if (!route_e) {
+                route_e = ar_entry_add(routes, advertising_od, advertising_op,
+                                       vip->vip_str, fallback_tracked_port,
+                                       ROUTE_SOURCE_LB);
+            }
+            route_e->distributed_lb = true;
         }
     }
-    SSET_FOR_EACH (ip_address, &lb_ips->ips_v6_adv) {
-        ar_entry_add(routes, advertising_od, advertising_op,
-                     ip_address, tracked_port, ROUTE_SOURCE_LB);
-        if (parsed_routes_out) {
-            add_redistribute_parsed_route(parsed_routes_out, advertising_od,
-                                          advertising_op, tracked_port,
-                                          ip_address, ROUTE_SOURCE_LB,
-                                          &advertising_op->nbrp->header_);
+}
+
+/* Process LBs attached directly to peer_lr_nbr and through its LB groups. */
+static void
+build_lb_lr_routes(const struct ovn_port *advertising_op,
+                   const struct ovn_port *fallback_tracked_port,
+                   const struct nbrec_logical_router *peer_lr_nbr,
+                   const struct hmap *lb_datapaths_map,
+                   const struct hmap *ls_ports,
+                   struct hmap *routes,
+                   struct hmap *parsed_routes_out)
+{
+    if (!peer_lr_nbr) {
+        return;
+    }
+
+    struct uuidset visited = UUIDSET_INITIALIZER(&visited);
+    for (size_t i = 0; i < peer_lr_nbr->n_load_balancer; i++) {
+        const struct nbrec_load_balancer *lb = peer_lr_nbr->load_balancer[i];
+        uuidset_insert(&visited, &lb->header_.uuid);
+        build_lb_routes_for_lb(advertising_op, fallback_tracked_port, lb,
+                               lb_datapaths_map, ls_ports, routes,
+                               parsed_routes_out);
+    }
+
+    for (size_t i = 0; i < peer_lr_nbr->n_load_balancer_group; i++) {
+        const struct nbrec_load_balancer_group *group =
+            peer_lr_nbr->load_balancer_group[i];
+        for (size_t j = 0; j < group->n_load_balancer; j++) {
+            const struct nbrec_load_balancer *lb = group->load_balancer[j];
+            if (uuidset_contains(&visited, &lb->header_.uuid)) {
+                continue;
+            }
+            uuidset_insert(&visited, &lb->header_.uuid);
+            build_lb_routes_for_lb(advertising_op, fallback_tracked_port, lb,
+                                   lb_datapaths_map, ls_ports, routes,
+                                   parsed_routes_out);
         }
     }
+    uuidset_destroy(&visited);
 }
 
 /* Similar to build_lb_routes, this function generates routes for LB VIPs
@@ -459,7 +619,8 @@ build_lb_route_for_port(const struct ovn_port 
*advertising_op,
  * LB VIPs too.*/
 static void
 build_lb_connected_routes(const struct ovn_datapath *od,
-                          const struct lr_stateful_table *lr_stateful_table,
+                          const struct hmap *lb_datapaths_map,
+                          const struct hmap *ls_ports,
                           struct dynamic_routes_data *data)
 {
     const struct ovn_port *op;
@@ -477,13 +638,11 @@ build_lb_connected_routes(const struct ovn_datapath *od,
         /* Track the peer datapath for any changes. */
         dynamic_routes_track_od(data, peer_od);
 
-        const struct lr_stateful_record *lr_stateful_rec;
         /* This is directly connected LR peer. */
         if (peer_od->nbr) {
-            lr_stateful_rec = lr_stateful_table_find_by_uuid(
-                lr_stateful_table, peer_od->key);
-            build_lb_route_for_port(op, op->peer, lr_stateful_rec->lb_ips,
-                                    &data->routes, &data->parsed_routes);
+            build_lb_lr_routes(op, op->peer, peer_od->nbr,
+                               lb_datapaths_map, ls_ports,
+                               &data->routes, &data->parsed_routes);
             continue;
         }
 
@@ -496,11 +655,9 @@ build_lb_connected_routes(const struct ovn_datapath *od,
                  * function.*/
                 continue;
             }
-            lr_stateful_rec = lr_stateful_table_find_by_uuid(
-                lr_stateful_table, rp->peer->od->key);
-
-            build_lb_route_for_port(op, rp->peer, lr_stateful_rec->lb_ips,
-                                    &data->routes, &data->parsed_routes);
+            build_lb_lr_routes(op, rp->peer, rp->peer->od->nbr,
+                               lb_datapaths_map, ls_ports,
+                               &data->routes, &data->parsed_routes);
             /* Track the LR datapath on the other side of LS
              * for any changes. */
             dynamic_routes_track_od(data, rp->peer->od);
@@ -508,9 +665,15 @@ build_lb_connected_routes(const struct ovn_datapath *od,
     }
 }
 
+/* Generate routes for LB VIPs owned by the advertising LR itself.
+ * Uses build_lb_lr_routes() so that distributed LBs emit per-backend
+ * Advertised_Route rows with backend LSPs as tracked ports, the same as
+ * for neighbor-owned LBs.  Forwarding parsed_routes are not needed here
+ * because the advertising LR's own pipeline handles ingress. */
 static void
 build_lb_routes(const struct ovn_datapath *od,
-                const struct ovn_lb_ip_set *lb_ips,
+                const struct hmap *lb_datapaths_map,
+                const struct hmap *ls_ports,
                 struct hmap *routes)
 {
     const struct ovn_port *op;
@@ -519,22 +682,14 @@ build_lb_routes(const struct ovn_datapath *od,
             continue;
         }
 
-        /* Traffic processed by a load balancer is:
-         * - handled by the chassis where a gateway router is bound
-         * OR
-         * - always redirected to a distributed gateway router port
-         *
-         * Advertise the LB IPs via all 'op' if this is a gateway router or
-         * through all DGPs of this distributed router otherwise. */
-
         if (od->is_gw_router) {
-            build_lb_route_for_port(op, NULL, lb_ips, routes,
-                                    NULL);
+            build_lb_lr_routes(op, NULL, od->nbr,
+                               lb_datapaths_map, ls_ports, routes, NULL);
         } else {
             struct ovn_port *dgp;
             VECTOR_FOR_EACH (&od->l3dgw_ports, dgp) {
-                build_lb_route_for_port(op, dgp, lb_ips, routes,
-                                        NULL);
+                build_lb_lr_routes(op, dgp, od->nbr,
+                                   lb_datapaths_map, ls_ports, routes, NULL);
             }
         }
     }
@@ -794,9 +949,11 @@ en_dynamic_routes_run(struct engine_node *node, void *data)
                                    &northd_data->ls_ports,
                                    dynamic_routes_data);
 
-        build_lb_routes(od, lr_stateful_rec->lb_ips,
+        build_lb_routes(od, &northd_data->lb_datapaths_map,
+                        &northd_data->ls_ports,
                         &dynamic_routes_data->routes);
-        build_lb_connected_routes(od, &lr_stateful_data->table,
+        build_lb_connected_routes(od, &northd_data->lb_datapaths_map,
+                                  &northd_data->ls_ports,
                                   dynamic_routes_data);
     }
 
@@ -879,6 +1036,10 @@ dynamic_routes_northd_change_handler(struct engine_node 
*node, void *data_)
         }
     }
 
+    /* Load balancer and datapath-association changes are handled through the
+     * lr_stateful input.  Its handler marks every affected LR record as
+     * updated, including association-only changes, and the handler above
+     * rebuilds dynamic routes when that LR is tracked in data->nb_lr. */
     return EN_HANDLED_UNCHANGED;
 }
 
@@ -971,17 +1132,15 @@ advertised_route_table_sync(
 
         const struct sbrec_port_binding *tracked_pb =
             route_e->tracked_port ? route_e->tracked_port->sb : NULL;
-        if (ar_entry_find(&sync_routes, route_e->od->sdp->sb_dp,
-                          route_e->op->sb,
-                          route_e->ip_prefix, tracked_pb)) {
-            /* We could already have advertised route entry for LRP IP that
-             * corresponds to "snat" when "connected-as-host" is combined
-             * with "nat". Skip it. */
-            continue;
+        struct ar_entry *sync_route = ar_entry_find(
+            &sync_routes, route_e->od->sdp->sb_dp, route_e->op->sb,
+            route_e->ip_prefix, tracked_pb);
+        if (!sync_route) {
+            sync_route = ar_entry_add(
+                &sync_routes, route_e->od, route_e->op, route_e->ip_prefix,
+                route_e->tracked_port, route_e->source);
         }
-        ar_entry_add(&sync_routes, route_e->od, route_e->op,
-                     route_e->ip_prefix, route_e->tracked_port,
-                     route_e->source);
+        ar_entry_merge_metadata(sync_route, route_e);
     }
 
     const struct sbrec_advertised_route *sb_route;
@@ -994,11 +1153,7 @@ advertised_route_table_sync(
             sbrec_advertised_route_delete(sb_route);
             continue;
         }
-
-        if (route_e->tracked_port && !sb_route->tracked_port) {
-            sbrec_advertised_route_set_tracked_port(
-                sb_route, route_e->tracked_port->sb);
-        }
+        ar_entry_sync_external_ids(sb_route, route_e);
         hmap_remove(&sync_routes, &route_e->hmap_node);
         ar_entry_free(route_e);
     }
@@ -1013,6 +1168,7 @@ advertised_route_table_sync(
             sbrec_advertised_route_set_tracked_port(sr,
                                                     route_e->tracked_port->sb);
         }
+        ar_entry_sync_external_ids(sr, route_e);
         ar_entry_free(route_e);
     }
 
diff --git a/ovn-sb.xml b/ovn-sb.xml
index e403eb360..4ad2c0b89 100644
--- a/ovn-sb.xml
+++ b/ovn-sb.xml
@@ -5109,8 +5109,8 @@ tcp.flags = RST;
       </p>
 
       <column name="type">
-        The type of the service. Supported values are "load-balancer" and
-        "network-function".
+        The type of the service. Supported values are "load-balancer",
+        "network-function", and "logical-switch-port".
       </column>
 
       <column name="ip">
@@ -5489,6 +5489,21 @@ tcp.flags = RST;
 
     <column name="external_ids">
       See <em>External IDs</em> at the beginning of this document.
+
+      <p>
+        <code>ovn-northd</code> sets the following key on routes
+        derived from Load_Balancer VIPs:
+      </p>
+
+      <dl>
+        <dt><code>source</code></dt>
+        <dd>
+          Set to <code>lb</code> by <code>ovn-northd</code> on routes
+          derived from Load_Balancer VIPs.  Absent on all other routes.
+          The key identifies the route's origin for consumers that
+          distinguish Load_Balancer-derived routes.
+        </dd>
+      </dl>
     </column>
   </table>
 
diff --git a/tests/ovn-northd.at b/tests/ovn-northd.at
index 4b3272817..f5c52f8b8 100644
--- a/tests/ovn-northd.at
+++ b/tests/ovn-northd.at
@@ -18638,6 +18638,547 @@ OVN_CLEANUP_NORTHD
 AT_CLEANUP
 ])
 
+OVN_FOR_EACH_NORTHD_NO_HV([
+AT_SETUP([dynamic-routing - LB redistribute uses backend LSP as tracked_port])
+AT_KEYWORDS([dynamic-routing])
+ovn_start
+
+# When the LB row's ip_port_mappings populate per-backend LSPs, northd
+# emits one Advertised_Route per (VIP, backend LSP) instead of a single
+# row using the peer LR's gateway LRP. That moves the chassis-locality
+# decision from a chassis-unbound patch port (the peer LRP) to the
+# actual backend port - the controller can then per-chassis install
+# the kernel route via dynamic-routing-redistribute-local-only=true.
+
+check ovn-nbctl lr-add lr0
+check ovn-nbctl set Logical_Router lr0 \
+    options:dynamic-routing=true       \
+    options:chassis=hv1
+check ovn-nbctl lrp-add lr0 lr0-up 00:00:00:00:00:01
+check ovn-nbctl lrp-set-options lr0-up dynamic-routing-redistribute=lb
+check ovn-nbctl ls-add up
+check ovn-nbctl lsp-add-router-port up up-lr0 lr0-up
+
+check ovn-nbctl lr-add lr1
+check ovn-nbctl lrp-add lr1 lr1-up 00:00:00:00:00:02 10.0.0.1/24
+check ovn-nbctl lsp-add-router-port up up-lr1 lr1-up
+
+# A backend logical switch hanging off lr1. The two backend LSPs are
+# what we want as tracked_port in Advertised_Route.
+check ovn-nbctl lrp-add lr1 lr1-be 00:00:00:00:00:03 192.168.1.1/24
+check ovn-nbctl ls-add be
+check ovn-nbctl lsp-add-router-port be be-lr1 lr1-be
+check ovn-nbctl lsp-add be be-vm1
+check ovn-nbctl lsp-set-addresses be-vm1 "00:00:00:00:01:01 192.168.1.10"
+check ovn-nbctl lsp-add be be-vm2
+check ovn-nbctl lsp-set-addresses be-vm2 "00:00:00:00:01:02 192.168.1.11"
+
+check ovn-nbctl \
+    -- lb-add lb0 172.16.1.10:80 192.168.1.10:80,192.168.1.11:80 \
+    -- set Load_Balancer lb0 options:distributed=true \
+        ip_port_mappings:192.168.1.10="be-vm1:192.168.1.1" \
+        ip_port_mappings:192.168.1.11="be-vm2:192.168.1.1"
+check ovn-nbctl --wait=sb sync
+check_row_count Advertised_Route 0
+
+# Exercise an association-only change: the LB row, VIPs, mappings and LSPs
+# are unchanged.  Only Logical_Router.load_balancer changes.
+check as northd ovn-appctl -t ovn-northd inc-engine/clear-stats
+check ovn-nbctl --wait=sb lr-lb-add lr1 lb0
+check_engine_compute dynamic_routes recompute
+
+datapath_lr0=$(fetch_column Datapath_Binding _uuid external_ids:name=lr0)
+pb_lr0_up=$(fetch_column Port_Binding _uuid logical_port=lr0-up)
+pb_lr1_up=$(fetch_column Port_Binding _uuid logical_port=lr1-up)
+pb_be_vm1=$(fetch_column Port_Binding _uuid logical_port=be-vm1)
+pb_be_vm2=$(fetch_column Port_Binding _uuid logical_port=be-vm2)
+
+# Expect two Advertised_Route rows - one per backend LSP.
+check_row_count Advertised_Route 2
+check_row_count Advertised_Route 1 \
+    ip_prefix="172.16.1.10"        \
+    datapath=$datapath_lr0         \
+    logical_port=$pb_lr0_up        \
+    tracked_port=$pb_be_vm1
+check_row_count Advertised_Route 1 \
+    ip_prefix="172.16.1.10"        \
+    datapath=$datapath_lr0         \
+    logical_port=$pb_lr0_up        \
+    tracked_port=$pb_be_vm2
+AT_CHECK([ovn-sbctl --bare --columns=external_ids \
+    find Advertised_Route ip_prefix=172.16.1.10 |
+    grep -c 'distributed-lb.*true'], [0], [2
+])
+
+# Forwarding parsed_route is still emitted once - the data-plane
+# decision is independent of backend identity.
+ovn-sbctl lflow-list lr0 > lr0_flows
+AT_CHECK([grep -c 'lr_in_ip_routing.*172.16.1.10/32' lr0_flows], [0], [1
+])
+
+# A centralized LB has one VIP-level route, no distributed-lb key and one
+# forwarding route.
+check ovn-nbctl --wait=sb set Load_Balancer lb0 options:distributed=false
+check_row_count Advertised_Route 1 \
+    ip_prefix="172.16.1.10"        \
+    datapath=$datapath_lr0         \
+    logical_port=$pb_lr0_up        \
+    tracked_port=$pb_lr1_up
+AT_CHECK([! ovn-sbctl --bare --columns=external_ids \
+    find Advertised_Route ip_prefix=172.16.1.10 |
+    grep -q 'distributed-lb'])
+ovn-sbctl lflow-list lr0 > lr0_flows_centralized
+AT_CHECK([grep -c 'lr_in_ip_routing.*172.16.1.10/32' \
+    lr0_flows_centralized], [0], [1
+])
+
+# Setting distributed=true restores the per-backend routes.  The single
+# forwarding route remains.
+check ovn-nbctl --wait=sb set Load_Balancer lb0 options:distributed=true
+check_row_count Advertised_Route 2
+AT_CHECK([ovn-sbctl --bare --columns=external_ids \
+    find Advertised_Route ip_prefix=172.16.1.10 |
+    grep -c 'distributed-lb.*true'], [0], [2
+])
+ovn-sbctl lflow-list lr0 > lr0_flows_distributed
+AT_CHECK([grep -c 'lr_in_ip_routing.*172.16.1.10/32' \
+    lr0_flows_distributed], [0], [1
+])
+
+# Without ip_port_mappings, the fallback path emits one row with
+# tracked_port = peer LRP (covered by the existing test). Re-derive
+# that by clearing ip_port_mappings and re-syncing.
+check ovn-nbctl --wait=sb clear Load_Balancer lb0 ip_port_mappings
+check_row_count Advertised_Route 1
+check_row_count Advertised_Route 1 \
+    ip_prefix="172.16.1.10"        \
+    datapath=$datapath_lr0         \
+    logical_port=$pb_lr0_up        \
+    tracked_port=$pb_lr1_up
+
+OVN_CLEANUP_NORTHD
+AT_CLEANUP
+])
+
+OVN_FOR_EACH_NORTHD_NO_HV([
+AT_SETUP([dynamic-routing - own-LR distributed LB per-backend route emission])
+AT_KEYWORDS([dynamic-routing])
+ovn_start
+
+# A distributed LB attached directly to the advertising LR must use
+# the per-backend path (build_lb_routes_for_lb), not the aggregated
+# lb_ips path, so that Advertised_Route rows carry distributed-lb=true,
+# and the backend LSP as tracked_port.
+
+check ovn-nbctl lr-add lr0
+check ovn-nbctl set Logical_Router lr0 \
+    options:dynamic-routing=true       \
+    options:chassis=hv1
+check ovn-nbctl lrp-add lr0 lr0-up 00:00:00:00:00:01 10.0.0.1/24
+check ovn-nbctl lrp-set-options lr0-up dynamic-routing-redistribute=lb
+check ovn-nbctl ls-add up
+check ovn-nbctl lsp-add-router-port up up-lr0 lr0-up
+
+check ovn-nbctl lrp-add lr0 lr0-be 00:00:00:00:00:03 192.168.1.1/24
+check ovn-nbctl ls-add be
+check ovn-nbctl lsp-add-router-port be be-lr0 lr0-be
+check ovn-nbctl lsp-add be be-vm1
+check ovn-nbctl lsp-set-addresses be-vm1 "00:00:00:00:01:01 192.168.1.10"
+check ovn-nbctl lsp-add be be-vm2
+check ovn-nbctl lsp-set-addresses be-vm2 "00:00:00:00:01:02 192.168.1.11"
+
+check ovn-nbctl \
+    -- lb-add lb0 172.16.1.10:80 192.168.1.10:80,192.168.1.11:80 \
+    -- set Load_Balancer lb0 options:distributed=true \
+        ip_port_mappings:192.168.1.10="be-vm1" \
+        ip_port_mappings:192.168.1.11="be-vm2"
+check ovn-nbctl --wait=sb lr-lb-add lr0 lb0
+
+datapath_lr0=$(fetch_column Datapath_Binding _uuid external_ids:name=lr0)
+pb_lr0_up=$(fetch_column Port_Binding _uuid logical_port=lr0-up)
+pb_be_vm1=$(fetch_column Port_Binding _uuid logical_port=be-vm1)
+pb_be_vm2=$(fetch_column Port_Binding _uuid logical_port=be-vm2)
+
+check_row_count Advertised_Route 2
+check_row_count Advertised_Route 1 \
+    ip_prefix="172.16.1.10"        \
+    datapath=$datapath_lr0         \
+    logical_port=$pb_lr0_up        \
+    tracked_port=$pb_be_vm1
+check_row_count Advertised_Route 1 \
+    ip_prefix="172.16.1.10"        \
+    datapath=$datapath_lr0         \
+    logical_port=$pb_lr0_up        \
+    tracked_port=$pb_be_vm2
+AT_CHECK([ovn-sbctl --bare --columns=external_ids \
+    find Advertised_Route ip_prefix=172.16.1.10 |
+    grep -c 'distributed-lb.*true'], [0], [2
+])
+
+OVN_CLEANUP_NORTHD
+AT_CLEANUP
+])
+
+OVN_FOR_EACH_NORTHD_NO_HV([
+AT_SETUP([dynamic-routing - LB redistribute SCTP per-backend route emission])
+AT_KEYWORDS([dynamic-routing])
+ovn_start
+
+# SCTP uses the same per-backend Advertised_Route representation as TCP
+# and UDP.
+
+check ovn-nbctl lr-add lr0
+check ovn-nbctl set Logical_Router lr0 \
+    options:dynamic-routing=true       \
+    options:chassis=hv1
+check ovn-nbctl lrp-add lr0 lr0-up 00:00:00:00:00:01
+check ovn-nbctl lrp-set-options lr0-up dynamic-routing-redistribute=lb
+check ovn-nbctl ls-add up
+check ovn-nbctl lsp-add-router-port up up-lr0 lr0-up
+
+check ovn-nbctl lr-add lr1
+check ovn-nbctl lrp-add lr1 lr1-up 00:00:00:00:00:02 10.0.0.1/24
+check ovn-nbctl lsp-add-router-port up up-lr1 lr1-up
+
+check ovn-nbctl lrp-add lr1 lr1-be 00:00:00:00:00:03 192.168.1.1/24
+check ovn-nbctl ls-add be
+check ovn-nbctl lsp-add-router-port be be-lr1 lr1-be
+check ovn-nbctl lsp-add be be-vm1
+check ovn-nbctl lsp-set-addresses be-vm1 "00:00:00:00:01:01 192.168.1.10"
+check ovn-nbctl lsp-add be be-vm2
+check ovn-nbctl lsp-set-addresses be-vm2 "00:00:00:00:01:02 192.168.1.11"
+
+check ovn-nbctl \
+    -- lb-add lb0 172.16.1.10:80 192.168.1.10:80,192.168.1.11:80 sctp \
+    -- set Load_Balancer lb0 options:distributed=true \
+        ip_port_mappings:192.168.1.10="be-vm1:192.168.1.1" \
+        ip_port_mappings:192.168.1.11="be-vm2:192.168.1.1" \
+    -- lr-lb-add lr1 lb0
+check ovn-nbctl --wait=sb sync
+
+datapath_lr0=$(fetch_column Datapath_Binding _uuid external_ids:name=lr0)
+pb_be_vm1=$(fetch_column Port_Binding _uuid logical_port=be-vm1)
+pb_be_vm2=$(fetch_column Port_Binding _uuid logical_port=be-vm2)
+
+# Expect two Advertised_Route rows - one per backend LSP.
+check_row_count Advertised_Route 2
+check_row_count Advertised_Route 1 \
+    ip_prefix="172.16.1.10"        \
+    datapath=$datapath_lr0         \
+    tracked_port=$pb_be_vm1
+check_row_count Advertised_Route 1 \
+    ip_prefix="172.16.1.10"        \
+    datapath=$datapath_lr0         \
+    tracked_port=$pb_be_vm2
+
+OVN_CLEANUP_NORTHD
+AT_CLEANUP
+])
+
+OVN_FOR_EACH_NORTHD_NO_HV([
+AT_SETUP([dynamic-routing - LB redistribute no ip_port_mappings fallback])
+AT_KEYWORDS([dynamic-routing])
+ovn_start
+
+# When an LB has no ip_port_mappings at all, the fallback path emits
+# one Advertised_Route per VIP with tracked_port set to the peer LRP.
+
+check ovn-nbctl lr-add lr0
+check ovn-nbctl set Logical_Router lr0 \
+    options:dynamic-routing=true       \
+    options:chassis=hv1
+check ovn-nbctl lrp-add lr0 lr0-up 00:00:00:00:00:01
+check ovn-nbctl lrp-set-options lr0-up dynamic-routing-redistribute=lb
+check ovn-nbctl ls-add up
+check ovn-nbctl lsp-add-router-port up up-lr0 lr0-up
+
+check ovn-nbctl lr-add lr1
+check ovn-nbctl lrp-add lr1 lr1-up 00:00:00:00:00:02 10.0.0.1/24
+check ovn-nbctl lsp-add-router-port up up-lr1 lr1-up
+check ovn-nbctl lrp-add lr1 lr1-be 00:00:00:00:00:03 192.168.1.1/24
+check ovn-nbctl ls-add be
+check ovn-nbctl lsp-add-router-port be be-lr1 lr1-be
+check ovn-nbctl lsp-add be be-vm1
+check ovn-nbctl lsp-set-addresses be-vm1 "00:00:00:00:01:01 192.168.1.10"
+check ovn-nbctl lsp-add be be-vm2
+check ovn-nbctl lsp-set-addresses be-vm2 "00:00:00:00:01:02 192.168.1.11"
+
+# LB with no ip_port_mappings at all.
+check ovn-nbctl \
+    -- lb-add lb0 172.16.1.10:80 192.168.1.10:80,192.168.1.11:80 \
+    -- set Load_Balancer lb0 options:distributed=true \
+    -- lr-lb-add lr1 lb0
+check ovn-nbctl --wait=sb sync
+
+datapath_lr0=$(fetch_column Datapath_Binding _uuid external_ids:name=lr0)
+pb_lr0_up=$(fetch_column Port_Binding _uuid logical_port=lr0-up)
+pb_lr1_up=$(fetch_column Port_Binding _uuid logical_port=lr1-up)
+
+# One Advertised_Route row with tracked_port = peer LRP (lr1-up),
+# not a per-backend row.
+check_row_count Advertised_Route 1
+check_row_count Advertised_Route 1 \
+    ip_prefix="172.16.1.10"        \
+    datapath=$datapath_lr0          \
+    logical_port=$pb_lr0_up         \
+    tracked_port=$pb_lr1_up
+
+OVN_CLEANUP_NORTHD
+AT_CLEANUP
+])
+
+OVN_FOR_EACH_NORTHD_NO_HV([
+AT_SETUP([dynamic-routing - LB redistribute partial ip_port_mappings])
+AT_KEYWORDS([dynamic-routing])
+ovn_start
+
+# When only some backends have ip_port_mappings entries, only those
+# backends should produce per-backend Advertised_Route rows.
+
+check ovn-nbctl lr-add lr0
+check ovn-nbctl set Logical_Router lr0 \
+    options:dynamic-routing=true       \
+    options:chassis=hv1
+check ovn-nbctl lrp-add lr0 lr0-up 00:00:00:00:00:01
+check ovn-nbctl lrp-set-options lr0-up dynamic-routing-redistribute=lb
+check ovn-nbctl ls-add up
+check ovn-nbctl lsp-add-router-port up up-lr0 lr0-up
+
+check ovn-nbctl lr-add lr1
+check ovn-nbctl lrp-add lr1 lr1-up 00:00:00:00:00:02 10.0.0.1/24
+check ovn-nbctl lsp-add-router-port up up-lr1 lr1-up
+
+check ovn-nbctl lrp-add lr1 lr1-be 00:00:00:00:00:03 192.168.1.1/24
+check ovn-nbctl ls-add be
+check ovn-nbctl lsp-add-router-port be be-lr1 lr1-be
+check ovn-nbctl lsp-add be be-vm1
+check ovn-nbctl lsp-set-addresses be-vm1 "00:00:00:00:01:01 192.168.1.10"
+check ovn-nbctl lsp-add be be-vm2
+check ovn-nbctl lsp-set-addresses be-vm2 "00:00:00:00:01:02 192.168.1.11"
+
+# Only map one backend initially.
+check ovn-nbctl \
+    -- lb-add lb0 172.16.1.10:80 192.168.1.10:80,192.168.1.11:80 \
+    -- set Load_Balancer lb0 options:distributed=true \
+        ip_port_mappings:192.168.1.10="be-vm1:192.168.1.1" \
+    -- lr-lb-add lr1 lb0
+check ovn-nbctl --wait=sb sync
+
+datapath_lr0=$(fetch_column Datapath_Binding _uuid external_ids:name=lr0)
+pb_be_vm1=$(fetch_column Port_Binding _uuid logical_port=be-vm1)
+pb_be_vm2=$(fetch_column Port_Binding _uuid logical_port=be-vm2)
+
+# Only one Advertised_Route row - for the mapped backend.
+check_row_count Advertised_Route 1
+check_row_count Advertised_Route 1 \
+    ip_prefix="172.16.1.10"        \
+    datapath=$datapath_lr0         \
+    tracked_port=$pb_be_vm1
+
+# Add the second mapping and re-sync.
+check ovn-nbctl --wait=sb set Load_Balancer lb0 \
+    ip_port_mappings:192.168.1.10="be-vm1:192.168.1.1" \
+    ip_port_mappings:192.168.1.11="be-vm2:192.168.1.1"
+
+# Now expect two rows - both backends mapped.
+check_row_count Advertised_Route 2
+check_row_count Advertised_Route 1 \
+    ip_prefix="172.16.1.10"        \
+    datapath=$datapath_lr0         \
+    tracked_port=$pb_be_vm1
+check_row_count Advertised_Route 1 \
+    ip_prefix="172.16.1.10"        \
+    datapath=$datapath_lr0         \
+    tracked_port=$pb_be_vm2
+
+OVN_CLEANUP_NORTHD
+AT_CLEANUP
+])
+
+OVN_FOR_EACH_NORTHD_NO_HV([
+AT_SETUP([dynamic-routing - LB redistribute IPv6 per-backend route emission])
+AT_KEYWORDS([dynamic-routing])
+ovn_start
+
+# IPv6 variant of the per-backend test: one Advertised_Route row
+# is emitted per backend LSP.
+
+check ovn-nbctl lr-add lr0
+check ovn-nbctl set Logical_Router lr0 \
+    options:dynamic-routing=true       \
+    options:chassis=hv1
+check ovn-nbctl lrp-add lr0 lr0-up 00:00:00:00:00:01
+check ovn-nbctl lrp-set-options lr0-up dynamic-routing-redistribute=lb
+check ovn-nbctl ls-add up
+check ovn-nbctl lsp-add-router-port up up-lr0 lr0-up
+
+check ovn-nbctl lr-add lr1
+check ovn-nbctl lrp-add lr1 lr1-up 00:00:00:00:00:02 2001:db8::1/64
+check ovn-nbctl lsp-add-router-port up up-lr1 lr1-up
+
+check ovn-nbctl lrp-add lr1 lr1-be 00:00:00:00:00:03 2001:db8:1::1/64
+check ovn-nbctl ls-add be
+check ovn-nbctl lsp-add-router-port be be-lr1 lr1-be
+check ovn-nbctl lsp-add be be-vm1
+check ovn-nbctl lsp-set-addresses be-vm1 "00:00:00:00:01:01 2001:db8:1::10"
+check ovn-nbctl lsp-add be be-vm2
+check ovn-nbctl lsp-set-addresses be-vm2 "00:00:00:00:01:02 2001:db8:1::11"
+
+check ovn-nbctl \
+    -- lb-add lb0 [[2001:db8:ffff::10]]:80 
[[2001:db8:1::10]]:80,[[2001:db8:1::11]]:80 \
+    -- set Load_Balancer lb0 options:distributed=true \
+        ip_port_mappings:\"[[2001:db8:1::10]]\"=\"be-vm1:[[2001:db8:1::1]]\" \
+        ip_port_mappings:\"[[2001:db8:1::11]]\"=\"be-vm2:[[2001:db8:1::1]]\" \
+    -- lr-lb-add lr1 lb0
+check ovn-nbctl --wait=sb sync
+
+datapath_lr0=$(fetch_column Datapath_Binding _uuid external_ids:name=lr0)
+pb_be_vm1=$(fetch_column Port_Binding _uuid logical_port=be-vm1)
+pb_be_vm2=$(fetch_column Port_Binding _uuid logical_port=be-vm2)
+
+# Expect two Advertised_Route rows - one per backend LSP.
+check_row_count Advertised_Route 2
+check_row_count Advertised_Route 1 \
+    ip_prefix="2001\:db8\:ffff\:\:10" \
+    datapath=$datapath_lr0           \
+    tracked_port=$pb_be_vm1
+check_row_count Advertised_Route 1 \
+    ip_prefix="2001\:db8\:ffff\:\:10" \
+    datapath=$datapath_lr0           \
+    tracked_port=$pb_be_vm2
+
+OVN_CLEANUP_NORTHD
+AT_CLEANUP
+])
+
+OVN_FOR_EACH_NORTHD_NO_HV([
+AT_SETUP([dynamic-routing - LB route external_ids reconciled on existing rows])
+AT_KEYWORDS([dynamic-routing])
+ovn_start
+
+# Northd sets external_ids:source=lb and external_ids:distributed-lb=true on
+# distributed LB routes.  It also restores missing keys when reconciling
+# existing rows.
+
+check ovn-nbctl lr-add lr0
+check ovn-nbctl set Logical_Router lr0 \
+    options:dynamic-routing=true       \
+    options:chassis=hv1
+check ovn-nbctl lrp-add lr0 lr0-up 00:00:00:00:00:01
+check ovn-nbctl lrp-set-options lr0-up dynamic-routing-redistribute=lb
+check ovn-nbctl ls-add up
+check ovn-nbctl lsp-add-router-port up up-lr0 lr0-up
+
+check ovn-nbctl lr-add lr1
+check ovn-nbctl lrp-add lr1 lr1-up 00:00:00:00:00:02 10.0.0.1/24
+check ovn-nbctl lsp-add-router-port up up-lr1 lr1-up
+check ovn-nbctl lrp-add lr1 lr1-be 00:00:00:00:00:03 192.168.1.1/24
+check ovn-nbctl ls-add be
+check ovn-nbctl lsp-add-router-port be be-lr1 lr1-be
+check ovn-nbctl lsp-add be be-vm1
+check ovn-nbctl lsp-set-addresses be-vm1 "00:00:00:00:01:01 192.168.1.10"
+check ovn-nbctl lsp-add be be-vm2
+check ovn-nbctl lsp-set-addresses be-vm2 "00:00:00:00:01:02 192.168.1.11"
+
+# Start with one mapped backend.
+check ovn-nbctl \
+    -- lb-add lb0 172.16.1.10:80 192.168.1.10:80,192.168.1.11:80 \
+    -- set Load_Balancer lb0 options:distributed=true \
+        ip_port_mappings:192.168.1.10="be-vm1:192.168.1.1" \
+    -- lr-lb-add lr1 lb0
+check ovn-nbctl --wait=sb sync
+
+# Check the initial row.
+check_row_count Advertised_Route 1
+AT_CHECK([ovn-sbctl --columns=external_ids find Advertised_Route \
+    ip_prefix="172.16.1.10" | grep -q 'source=lb'])
+AT_CHECK([ovn-sbctl --columns=external_ids find Advertised_Route \
+    ip_prefix="172.16.1.10" | grep -q 'distributed-lb.*true'])
+
+# Add the second backend mapping -- this triggers a northd recompute
+# that reuses the existing row (same tracked_port=be-vm1) and adds
+# a new row for be-vm2.
+check ovn-nbctl --wait=sb set Load_Balancer lb0 \
+    ip_port_mappings:192.168.1.10="be-vm1:192.168.1.1" \
+    ip_port_mappings:192.168.1.11="be-vm2:192.168.1.1"
+
+# Check the row added for the second backend.
+check_row_count Advertised_Route 2
+AT_CHECK([ovn-sbctl --columns=external_ids find Advertised_Route \
+    ip_prefix="172.16.1.10" | grep -c 'source=lb'], [0], [2
+])
+AT_CHECK([ovn-sbctl --columns=external_ids find Advertised_Route \
+    ip_prefix="172.16.1.10" | grep -c 'distributed-lb.*true'], [0], [2
+])
+
+# Remove the keys and trigger reconciliation to model rows created by an
+# older northd.
+AT_CHECK([
+for uuid in $(ovn-sbctl --columns=_uuid --no-headings --bare find \
+    Advertised_Route ip_prefix=172.16.1.10); do
+    (ovn-sbctl remove Advertised_Route "$uuid" external_ids source \
+        2>/dev/null) || true
+    (ovn-sbctl remove Advertised_Route "$uuid" external_ids distributed-lb \
+        2>/dev/null) || true
+done
+], [0], [ignore], [ignore])
+
+# Confirm that the direct SB update took effect.
+AT_CHECK([! ovn-sbctl --columns=external_ids find Advertised_Route \
+    ip_prefix="172.16.1.10" | grep -q 'source=lb'])
+AT_CHECK([! ovn-sbctl --columns=external_ids find Advertised_Route \
+    ip_prefix="172.16.1.10" | grep -q 'distributed-lb'])
+
+# Reuse the same rows with a modified ip_port_mappings value.
+check ovn-nbctl --wait=sb set Load_Balancer lb0 \
+    ip_port_mappings:192.168.1.10="be-vm1:192.168.1.1" \
+    ip_port_mappings:192.168.1.11="be-vm2:192.168.1.2"
+
+# Northd restores both keys.
+AT_CHECK([ovn-sbctl --columns=external_ids find Advertised_Route \
+    ip_prefix="172.16.1.10" | grep -c 'source=lb'], [0], [2
+])
+AT_CHECK([ovn-sbctl --columns=external_ids find Advertised_Route \
+    ip_prefix="172.16.1.10" | grep -c 'distributed-lb.*true'], [0], [2
+])
+
+OVN_CLEANUP_NORTHD
+AT_CLEANUP
+])
+
+OVN_FOR_EACH_NORTHD_NO_HV([
+AT_SETUP([dynamic-routing - non-LB routes do not carry the source=lb key])
+AT_KEYWORDS([dynamic-routing])
+ovn_start
+
+# Regression test: routes from NAT redistribution must not carry
+# external_ids:source=lb.
+
+check ovn-nbctl lr-add lr0
+check ovn-nbctl set Logical_Router lr0 \
+    options:dynamic-routing=true       \
+    options:chassis=hv1
+check ovn-nbctl lrp-add lr0 lr0-up 00:00:00:00:00:01
+check ovn-nbctl lrp-set-options lr0-up dynamic-routing-redistribute=nat
+check ovn-nbctl ls-add up
+check ovn-nbctl lsp-add-router-port up up-lr0 lr0-up
+
+check ovn-nbctl lr-add lr1
+check ovn-nbctl lrp-add lr1 lr1-up 00:00:00:00:00:02 10.0.0.1/24
+check ovn-nbctl lrp-set-gateway-chassis lr1-up hv1
+check ovn-nbctl lsp-add-router-port up up-lr1 lr1-up
+check ovn-nbctl --add-route lr-nat-add lr1 dnat_and_snat 172.16.1.10 
192.168.1.10
+check ovn-nbctl --wait=sb sync
+
+# NAT-derived row must not have source=lb.
+check_row_count Advertised_Route 1
+AT_CHECK([! ovn-sbctl --columns=external_ids find Advertised_Route \
+    ip_prefix="172.16.1.10" | grep -q 'source=lb'])
+
+OVN_CLEANUP_NORTHD
+AT_CLEANUP
+])
+
 OVN_FOR_EACH_NORTHD_NO_HV([
 AT_SETUP([dynamic-routing - LB forwarding route updates on nexthop change])
 AT_KEYWORDS([dynamic-routing])
@@ -18663,6 +19204,7 @@ check ovn-nbctl lsp-add-router-port up up-lr1 lr1-up
 
 check ovn-nbctl \
     -- lb-add lb0 172.16.1.10:80 192.168.1.10:80 \
+    -- set Load_Balancer lb0 options:distributed=true \
     -- lr-lb-add lr1 lb0
 check ovn-nbctl --wait=sb sync
 
@@ -18713,7 +19255,8 @@ check ovn-nbctl lsp-add-router-port up up-lr1 lr1-up
 
 check ovn-nbctl \
     -- lb-add lb0 172.16.1.10:80 192.168.1.10:80 \
-    -- set Load_Balancer lb0 options:dynamic-routing-advertise=false \
+    -- set Load_Balancer lb0 options:distributed=true \
+        options:dynamic-routing-advertise=false \
     -- lr-lb-add lr1 lb0
 check ovn-nbctl --wait=sb sync
 
diff --git a/tests/system-ovn.at b/tests/system-ovn.at
index f8c9e5101..06372dc9b 100644
--- a/tests/system-ovn.at
+++ b/tests/system-ovn.at
@@ -22620,5 +22620,145 @@ as
 OVS_TRAFFIC_VSWITCHD_STOP(["/.*error receiving.*/d
 /failed to query port patch-.*/d
 /.*terminating with signal 15.*/d"])
+
+AT_CLEANUP
+])
+
+AT_SETUP([dynamic-routing - LB redistribute local-only restricts to backend 
chassis])
+AT_KEYWORDS([dynamic-routing])
+
+VRF_RESERVE([1342])
+
+# With dynamic-routing-redistribute-local-only, the kernel route for a
+# distributed LB VIP appears only on the chassis hosting the backend LSP
+# used as tracked_port.  Moving the backend to another chassis withdraws
+# the route.  Moving it back reinstalls it.
+
+ovn_start
+OVS_TRAFFIC_VSWITCHD_START()
+
+ADD_BR([br-int])
+check ovs-vsctl \
+    -- set Open_vSwitch . external-ids:system-id=hv1 \
+    -- set Open_vSwitch . 
external-ids:ovn-remote=unix:$ovs_base/ovn-sb/ovn-sb.sock \
+    -- set Open_vSwitch . external-ids:ovn-encap-type=geneve \
+    -- set Open_vSwitch . external-ids:ovn-encap-ip=169.0.0.1 \
+    -- set bridge br-int fail-mode=secure other-config:disable-in-band=true
+
+start_daemon ovn-controller
+
+# Shared LS holding both LR LRPs and the backend LSP.
+check ovn-nbctl ls-add ls-share
+
+# lr-origin: GW with redistribute=lb and local-only on its LRP into ls-share.
+check ovn-nbctl lr-add lr-origin \
+    -- set Logical_Router lr-origin options:chassis=hv1 \
+                                    options:dynamic-routing=true \
+                                    options:dynamic-routing-vrf-id=1342
+check ovn-nbctl lrp-add lr-origin lr-origin-share 00:de:ad:00:00:01 \
+        192.168.0.1/24 \
+    -- set Logical_Router_Port lr-origin-share \
+            options:dynamic-routing-redistribute="lb" \
+            options:dynamic-routing-redistribute-local-only=true \
+            options:dynamic-routing-maintain-vrf=true
+check ovn-nbctl lsp-add ls-share share-lr-origin \
+    -- set Logical_Switch_Port share-lr-origin type=router \
+                                               
options:router-port=lr-origin-share \
+    -- lsp-set-addresses share-lr-origin router
+
+# lr-target: GW, owns the LB.
+check ovn-nbctl lr-add lr-target \
+    -- set Logical_Router lr-target options:chassis=hv1
+check ovn-nbctl lrp-add lr-target lr-target-share 00:de:ad:00:00:02 \
+        192.168.0.2/24
+check ovn-nbctl lsp-add ls-share share-lr-target \
+    -- set Logical_Switch_Port share-lr-target type=router \
+                                               
options:router-port=lr-target-share \
+    -- lsp-set-addresses share-lr-target router
+
+# Backend LSP on the shared LS.  Veth-backed so it is claimed by hv1.
+check ovn-nbctl lsp-add ls-share be0
+check ovn-nbctl lsp-set-addresses be0 "00:de:ad:00:00:10 192.168.0.10"
+ADD_NAMESPACES(be0_ns)
+ADD_VETH(be0, be0_ns, br-int, "192.168.0.10/24", "00:de:ad:00:00:10")
+
+# Distributed LB with one VIP and one backend.  This test isolates the
+# local-only route-selection mechanism.
+check ovn-nbctl \
+    -- lb-add lb0 172.16.1.10:80 192.168.0.10:80 \
+    -- set Load_Balancer lb0 options:distributed=true \
+        ip_port_mappings:192.168.0.10="be0:192.168.0.2" \
+    -- lr-lb-add lr-target lb0
+
+check ovn-nbctl --wait=hv sync
+wait_for_ports_up
+OVS_CTL_TIMEOUT=30
+
+OVS_WAIT_UNTIL([test -n "`ip vrf show ovnvrf1342 2>/dev/null`"])
+
+# The backend is on hv1, so the route is installed locally.
+wait_row_count sb:Advertised_Route 1 ip_prefix='"172.16.1.10"'
+OVS_WAIT_UNTIL([
+    ip route list vrf ovnvrf1342 | grep -q "blackhole 172.16.1.10"])
+
+# Move the backend to a simulated hv2 by deleting the veth so hv1
+# drops the claim, then bind be0 to hv2.
+check ovs-vsctl del-port br-int ovs-be0
+wait_column "" Port_Binding chassis logical_port=be0
+check ovn-sbctl chassis-add hv2 geneve 169.0.0.2
+hv2=$(fetch_column Chassis _uuid name=hv2)
+check ovn-sbctl set Port_Binding be0 chassis=$hv2
+
+# The backend is no longer on hv1.  With local-only the route must be
+# withdrawn from hv1's kernel.
+OVS_WAIT_UNTIL([
+    ! ip route list vrf ovnvrf1342 | grep -q "blackhole 172.16.1.10"])
+
+# Return the backend to hv1.
+check ovn-sbctl clear Port_Binding be0 chassis
+check ovn-sbctl destroy Chassis $hv2
+check ovs-vsctl add-port br-int ovs-be0 \
+    -- set Interface ovs-be0 external_ids:iface-id=be0
+hv1_uuid=$(fetch_column Chassis _uuid name=hv1)
+wait_row_count Port_Binding 1 logical_port=be0 chassis=$hv1_uuid
+
+# The route reappears now that the backend is local again.
+OVS_WAIT_UNTIL([
+    ip route list vrf ovnvrf1342 | grep -q "blackhole 172.16.1.10"])
+
+# Disable local-only.  Even with the backend on a remote chassis the
+# route stays because local-only no longer restricts installation.
+check ovn-nbctl --wait=hv set Logical_Router_Port lr-origin-share \
+    options:dynamic-routing-redistribute-local-only=false
+
+check ovs-vsctl del-port br-int ovs-be0
+wait_column "" Port_Binding chassis logical_port=be0
+check ovn-sbctl chassis-add hv2 geneve 169.0.0.2
+hv2=$(fetch_column Chassis _uuid name=hv2)
+check ovn-sbctl set Port_Binding be0 chassis=$hv2
+
+# Without local-only, the advertising chassis still installs the route
+# even though the backend is remote.
+OVS_WAIT_UNTIL([
+    ip route list vrf ovnvrf1342 | grep -q "blackhole 172.16.1.10"])
+
+# Cleanup.
+check ovn-sbctl destroy Chassis $hv2
+
+OVS_APP_EXIT_AND_WAIT([ovn-controller])
+
+as ovn-sb
+OVS_APP_EXIT_AND_WAIT([ovsdb-server])
+
+as ovn-nb
+OVS_APP_EXIT_AND_WAIT([ovsdb-server])
+
+as northd
+OVS_APP_EXIT_AND_WAIT([ovn-northd])
+
+as
+OVS_TRAFFIC_VSWITCHD_STOP(["/failed to query port patch-.*/d
+/connection dropped.*/d"])
+
 AT_CLEANUP
 ])
-- 
2.53.0


_______________________________________________
dev mailing list
[email protected]
https://mail.openvswitch.org/mailman/listinfo/ovs-dev

Reply via email to