For FDB entries with multiple paths (ECMP/multi-homed), create an
OpenFlow select group with one bucket per path.  Each bucket loads
its binding_key into MFF_LOG_REMOTE_OUTPORT with the nexthop weight
from the kernel. Single-path entries continue to use a direct load.

Reported-at: https://redhat.atlassian.net/browse/FDP-3068
Assisted-by: Claude Opus 4.6, Claude Code
Signed-off-by: Ales Musil <[email protected]>
---
 NEWS                             |   3 +
 controller/evpn-fdb.c            | 161 ++++++++++++++++++++++++++++---
 controller/evpn-fdb.h            |  25 ++++-
 controller/ovn-controller.c      |  17 +++-
 controller/physical.c            |  80 ++++++++++++---
 controller/physical.h            |   3 +
 tests/ovn-inc-proc-graph-dump.at |   2 +-
 tests/ovn-macros.at              |  13 +++
 tests/system-ovn.at              | 144 ++++++++++++++++++++++++---
 9 files changed, 399 insertions(+), 49 deletions(-)

diff --git a/NEWS b/NEWS
index 8633ba8bb..68cdbffea 100644
--- a/NEWS
+++ b/NEWS
@@ -3,6 +3,9 @@ Post v26.03.0
    - Dynamic Routing:
      * Add support for hub-and-spoke propagation via the "hub-spoke" option
        in dynamic-routing-redistribute settings.
+     * Add ECMP/multi-homing support for EVPN FDB entries. FDB entries
+       backed by a kernel nexthop group are load-balanced via OpenFlow
+       select groups with weighted buckets.
 
 OVN v26.03.0 - xxx xx xxxx
 --------------------------
diff --git a/controller/evpn-fdb.c b/controller/evpn-fdb.c
index 6767a70a5..9da3e86e5 100644
--- a/controller/evpn-fdb.c
+++ b/controller/evpn-fdb.c
@@ -15,10 +15,15 @@
 
 #include <config.h>
 
+#include <string.h>
+
 #include "evpn-binding.h"
+#include "local_data.h"
 #include "neighbor-exchange.h"
+#include "nexthop-exchange.h"
 #include "openvswitch/dynamic-string.h"
 #include "openvswitch/vlog.h"
+#include "ovn-sb-idl.h"
 #include "packets.h"
 #include "unixctl.h"
 
@@ -34,6 +39,104 @@ static struct evpn_fdb *evpn_fdb_find(const struct hmap 
*evpn_fdbs,
                                       struct eth_addr, uint32_t vni);
 static uint32_t evpn_fdb_hash(const struct eth_addr *mac, uint32_t vni);
 
+static int
+evpn_fdb_path_cmp(const void *a, const void *b)
+{
+    const struct evpn_fdb_path *pa = a;
+    const struct evpn_fdb_path *pb = b;
+
+    if (pa->binding_key != pb->binding_key) {
+        return pa->binding_key < pb->binding_key ? -1 : 1;
+    }
+
+    if (pa->weight != pb->weight) {
+        return pa->weight < pb->weight ? -1 : 1;
+    }
+
+    return 0;
+}
+
+/* Build a sorted vector of paths for 'static_fdb'.
+ * For single-path entries (nh_id == 0), looks up a single binding by IP.
+ * For ECMP entries (nh_id != 0), resolves the nexthop group and collects
+ * one path per gateway, preserving nexthop weights. */
+static void
+evpn_fdb_resolve_paths(const struct evpn_fdb_ctx_in *f_ctx_in,
+                       const struct evpn_static_entry *static_fdb,
+                       struct vector *paths)
+{
+    if (!static_fdb->nh_id) {
+        /* Single-path: direct VTEP IP. */
+        const struct evpn_binding *binding =
+            evpn_binding_find(f_ctx_in->bindings, &static_fdb->ip,
+                              static_fdb->vni);
+        if (!binding) {
+            return;
+        }
+        struct evpn_fdb_path path = {
+            .binding_key = binding->binding_key,
+            .weight = 0,
+        };
+        vector_push(paths, &path);
+        return;
+    }
+
+    /* ECMP: resolve nexthop group to multiple paths. */
+    const struct nexthop_entry *nhe =
+        nexthop_entry_find(f_ctx_in->nexthops, static_fdb->nh_id);
+    if (!nhe) {
+        VLOG_WARN_RL(&rl, "Couldn't find nexthop %"PRIu32" for "
+                     ETH_ADDR_FMT" MAC address.",
+                     static_fdb->nh_id, ETH_ADDR_ARGS(static_fdb->mac));
+        return;
+    }
+
+    if (!nhe->n_grps) {
+        VLOG_WARN_RL(&rl, "Nexthop %"PRIu32" for "
+                     ETH_ADDR_FMT" MAC address is not a group.",
+                     static_fdb->nh_id, ETH_ADDR_ARGS(static_fdb->mac));
+        return;
+    }
+
+    for (size_t i = 0; i < nhe->n_grps; i++) {
+        const struct nexthop_grp_entry *grp = &nhe->grps[i];
+        if (!grp->gateway) {
+            continue;
+        }
+
+        const struct evpn_binding *binding =
+            evpn_binding_find(f_ctx_in->bindings, &grp->gateway->addr,
+                              static_fdb->vni);
+        if (!binding) {
+            VLOG_WARN_RL(&rl, "Couldn't find EVPN binding for nexthop "
+                         "group member %"PRIu32" (gateway id %"PRIu32").",
+                         static_fdb->nh_id, grp->id);
+            continue;
+        }
+
+        struct evpn_fdb_path path = {
+            .binding_key = binding->binding_key,
+            .weight = grp->weight,
+        };
+        vector_push(paths, &path);
+    }
+
+    /* Sort so that memcmp-based comparison is deterministic. */
+    vector_qsort(paths, evpn_fdb_path_cmp);
+}
+
+/* Returns true if the paths in 'fdb' match the contents of 'paths'.
+ * Caller must ensure 'paths' is non-empty (memcmp with NULL is UB). */
+static bool
+evpn_fdb_paths_equal(const struct evpn_fdb *fdb,
+                     const struct vector *paths)
+{
+    return vector_len(&fdb->paths) == vector_len(paths)
+           && !memcmp(vector_get_array(&fdb->paths),
+                      vector_get_array(paths),
+                      vector_len(paths) * sizeof(struct evpn_fdb_path));
+}
+
 void
 evpn_fdb_run(const struct evpn_fdb_ctx_in *f_ctx_in,
              struct evpn_fdb_ctx_out *f_ctx_out)
@@ -45,17 +148,30 @@ evpn_fdb_run(const struct evpn_fdb_ctx_in *f_ctx_in,
         hmapx_add(&stale_fdbs, fdb);
     }
 
+    struct vector paths = VECTOR_EMPTY_INITIALIZER(struct evpn_fdb_path);
+
     const struct evpn_static_entry *static_fdb;
     HMAP_FOR_EACH (static_fdb, hmap_node, f_ctx_in->static_fdbs) {
-        const struct evpn_binding *binding =
-            evpn_binding_find(f_ctx_in->bindings, &static_fdb->ip,
-                              static_fdb->vni);
-        if (!binding) {
-            VLOG_WARN_RL(&rl, "Couldn't find EVPN binding for "ETH_ADDR_FMT" "
-                         "MAC address.", ETH_ADDR_ARGS(static_fdb->mac));
+        vector_clear(&paths);
+
+        evpn_fdb_resolve_paths(f_ctx_in, static_fdb, &paths);
+        if (vector_is_empty(&paths)) {
+            VLOG_WARN_RL(&rl, "Couldn't resolve EVPN bindings for "
+                         ETH_ADDR_FMT" MAC address.",
+                         ETH_ADDR_ARGS(static_fdb->mac));
             continue;
         }
 
+        const struct evpn_datapath *edp =
+            evpn_datapath_find(f_ctx_in->datapaths, static_fdb->vni);
+        if (!edp) {
+            VLOG_WARN_RL(&rl, "Couldn't find EVPN datapath for VNI %"PRIu32
+                         " ("ETH_ADDR_FMT" MAC address).",
+                         static_fdb->vni, ETH_ADDR_ARGS(static_fdb->mac));
+            continue;
+        }
+        uint32_t dp_key = edp->ldp->datapath->tunnel_key;
+
         fdb = evpn_fdb_find(f_ctx_out->fdbs, static_fdb->mac, static_fdb->vni);
         if (!fdb) {
             fdb = evpn_fdb_add(f_ctx_out->fdbs, static_fdb->mac,
@@ -63,13 +179,16 @@ evpn_fdb_run(const struct evpn_fdb_ctx_in *f_ctx_in,
         }
 
         bool updated = false;
-        if (fdb->binding_key != binding->binding_key) {
-            fdb->binding_key = binding->binding_key;
+        if (!evpn_fdb_paths_equal(fdb, &paths)) {
+            vector_clear(&fdb->paths);
+            vector_push_array(&fdb->paths,
+                              vector_get_array(&paths),
+                              vector_len(&paths));
             updated = true;
         }
 
-        if (fdb->dp_key != binding->dp_key) {
-            fdb->dp_key = binding->dp_key;
+        if (fdb->dp_key != dp_key) {
+            fdb->dp_key = dp_key;
             updated = true;
         }
 
@@ -80,12 +199,15 @@ evpn_fdb_run(const struct evpn_fdb_ctx_in *f_ctx_in,
         hmapx_find_and_delete(&stale_fdbs, fdb);
     }
 
+    vector_destroy(&paths);
+
     struct hmapx_node *node;
     HMAPX_FOR_EACH (node, &stale_fdbs) {
         fdb = node->data;
 
         uuidset_insert(f_ctx_out->removed_fdbs, &fdb->flow_uuid);
         hmap_remove(f_ctx_out->fdbs, &fdb->hmap_node);
+        vector_destroy(&fdb->paths);
         free(fdb);
     }
 
@@ -97,6 +219,7 @@ evpn_fdbs_destroy(struct hmap *fdbs)
 {
     struct evpn_fdb *fdb;
     HMAP_FOR_EACH_POP (fdb, hmap_node, fdbs) {
+        vector_destroy(&fdb->paths);
         free(fdb);
     }
     hmap_destroy(fdbs);
@@ -112,10 +235,21 @@ evpn_fdb_list(struct unixctl_conn *conn, int argc 
OVS_UNUSED,
     const struct evpn_fdb *fdb;
     HMAP_FOR_EACH (fdb, hmap_node, fdbs) {
         ds_put_format(&ds, "UUID: "UUID_FMT", MAC: "ETH_ADDR_FMT", "
-                      "vni: %"PRIu32", binding_key: %#"PRIx32", "
-                      "dp_key: %"PRIu32"\n",
+                      "vni: %"PRIu32", dp_key: %"PRIu32
+                      ", paths: [",
                       UUID_ARGS(&fdb->flow_uuid), ETH_ADDR_ARGS(fdb->mac),
-                      fdb->vni, fdb->binding_key, fdb->dp_key);
+                      fdb->vni, fdb->dp_key);
+
+        for (size_t i = 0; i < vector_len(&fdb->paths); i++) {
+            if (i) {
+                ds_put_cstr(&ds, ", ");
+            }
+            struct evpn_fdb_path path =
+                vector_get(&fdb->paths, i, struct evpn_fdb_path);
+            ds_put_format(&ds, "{key=%#"PRIx32", weight=%"PRIu16"}",
+                          path.binding_key, path.weight);
+        }
+        ds_put_cstr(&ds, "]\n");
     }
 
     unixctl_command_reply(conn, ds_cstr_ro(&ds));
@@ -130,6 +264,7 @@ evpn_fdb_add(struct hmap *evpn_fdbs, struct eth_addr mac, 
uint32_t vni)
         .flow_uuid = uuid_random(),
         .mac = mac,
         .vni = vni,
+        .paths = VECTOR_EMPTY_INITIALIZER(struct evpn_fdb_path),
     };
 
     uint32_t hash = evpn_fdb_hash(&mac, vni);
diff --git a/controller/evpn-fdb.h b/controller/evpn-fdb.h
index 35c5db040..65e1fc4fd 100644
--- a/controller/evpn-fdb.h
+++ b/controller/evpn-fdb.h
@@ -21,6 +21,7 @@
 #include "hmapx.h"
 #include "openvswitch/hmap.h"
 #include "uuidset.h"
+#include "vec.h"
 
 struct unixctl_conn;
 
@@ -29,27 +30,41 @@ struct evpn_fdb_ctx_in {
     const struct hmap *bindings;
     /* Contains 'struct evpn_static_entry', one for each FDB. */
     const struct hmap *static_fdbs;
+    /* Contains 'struct nexthop_entry'. */
+    const struct hmap *nexthops;
+    /* Contains 'struct evpn_datapath'. */
+    const struct hmap *datapaths;
 };
 
 struct evpn_fdb_ctx_out {
     /* Contains 'struct evpn_fdb'. */
     struct hmap *fdbs;
-    /* Contains pointers to 'struct evpn_binding'. */
+    /* Contains pointers to 'struct evpn_fdb'. */
     struct hmapx *updated_fdbs;
-    /* Contains 'flow_uuid' from removed 'struct evpn_binding'. */
+    /* Contains 'flow_uuid' from removed 'struct evpn_fdb'. */
     struct uuidset *removed_fdbs;
 };
 
+struct evpn_fdb_path {
+    uint32_t binding_key;
+    /* Nexthop weight from the kernel nexthop group.
+     * 0 for single-path entries (no ECMP). */
+    uint16_t weight;
+    /* Must be zero; memcmp-based equality depends on it. */
+    uint16_t pad;
+};
+
 struct evpn_fdb {
     struct hmap_node hmap_node;
     /* UUID used to identify physical flows related to this FDB. */
     struct uuid flow_uuid;
-    /* IP address of the remote VTEP. */
+    /* MAC address of the remote workload. */
     struct eth_addr mac;
     uint32_t vni;
-    /* Local tunnel key to identify the binding. */
-    uint32_t binding_key;
     uint32_t dp_key;
+    /* Contains 'struct evpn_fdb_path', one per ECMP path.
+     * For single-path entries len == 1. */
+    struct vector paths;
 };
 
 void evpn_fdb_run(const struct evpn_fdb_ctx_in *, struct evpn_fdb_ctx_out *);
diff --git a/controller/ovn-controller.c b/controller/ovn-controller.c
index 6f7578582..14279a897 100644
--- a/controller/ovn-controller.c
+++ b/controller/ovn-controller.c
@@ -4641,6 +4641,8 @@ struct ed_type_pflow_output {
     struct ovn_desired_flow_table flow_table;
     /* Drop debugging options. */
     struct physical_debug debug;
+    /* Shared group table from lflow_output, set during main loop init. */
+    struct ovn_extend_table *group_table;
 };
 
 static void
@@ -4784,6 +4786,8 @@ static void init_physical_ctx(struct engine_node *node,
     struct ed_type_evpn_arp *earp_data =
         engine_get_input_data("evpn_arp", node);
 
+    struct ed_type_pflow_output *pfo = engine_get_internal_data(node);
+
     parse_encap_ips(ovs_table, &p_ctx->n_encap_ips, &p_ctx->encap_ips);
     p_ctx->sbrec_port_binding_by_name = sbrec_port_binding_by_name;
     p_ctx->sbrec_port_binding_by_datapath = sbrec_port_binding_by_datapath;
@@ -4808,6 +4812,7 @@ static void init_physical_ctx(struct engine_node *node,
     p_ctx->evpn_multicast_groups = &eb_data->multicast_groups;
     p_ctx->evpn_fdbs = &efdb_data->fdbs;
     p_ctx->evpn_arps = &earp_data->arps;
+    p_ctx->group_table = pfo->group_table;
 
     struct controller_engine_ctx *ctrl_ctx = engine_get_context()->client_ctx;
     p_ctx->if_mgr = ctrl_ctx->if_mgr;
@@ -5127,7 +5132,8 @@ pflow_output_fdb_handler(struct engine_node *node, void 
*data)
     struct ed_type_evpn_fdb *ef_data =
         engine_get_input_data("evpn_fdb", node);
 
-    physical_handle_evpn_fdb_changes(&pfo->flow_table, &ef_data->updated_fdbs,
+    physical_handle_evpn_fdb_changes(&pfo->flow_table, pfo->group_table,
+                                     &ef_data->updated_fdbs,
                                      &ef_data->removed_fdbs);
     return EN_HANDLED_UPDATED;
 }
@@ -6712,10 +6718,14 @@ en_evpn_fdb_run(struct engine_node *node, void *data_)
         engine_get_input_data("neighbor_exchange", node);
     const struct ed_type_evpn_vtep_binding *eb_data =
         engine_get_input_data("evpn_vtep_binding", node);
+    const struct ed_type_nexthop_exchange *nhe_data =
+        engine_get_input_data("nexthop_exchange", node);
 
     struct evpn_fdb_ctx_in f_ctx_in = {
         .static_fdbs = &ne_data->static_fdbs,
         .bindings = &eb_data->bindings,
+        .nexthops = &nhe_data->nexthops,
+        .datapaths = &eb_data->datapaths,
     };
 
     struct evpn_fdb_ctx_out f_ctx_out = {
@@ -7216,9 +7226,7 @@ inc_proc_ovn_controller_init(
     engine_add_input(&en_evpn_fdb, &en_neighbor_exchange, NULL);
     engine_add_input(&en_evpn_fdb, &en_evpn_vtep_binding,
                      evpn_fdb_vtep_binding_handler);
-    /* XXX: This is just a place holder and it will be updated later on. */
-    engine_add_input(&en_evpn_fdb, &en_nexthop_exchange,
-                     engine_noop_handler);
+    engine_add_input(&en_evpn_fdb, &en_nexthop_exchange, NULL);
 
     engine_add_input(&en_evpn_arp, &en_neighbor_exchange, NULL);
     engine_add_input(&en_evpn_arp, &en_evpn_vtep_binding,
@@ -7646,6 +7654,7 @@ main(int argc, char *argv[])
     struct ovn_extend_table group_table;
     ovn_extend_table_init(&group_table, "group-table", 0);
     lflow_output_data->group_table = &group_table;
+    pflow_output_data->group_table = &group_table;
 
     ofctrl_init(&group_table, &lflow_output_data->meter_table);
 
diff --git a/controller/physical.c b/controller/physical.c
index 228f3d171..348c55560 100644
--- a/controller/physical.c
+++ b/controller/physical.c
@@ -17,6 +17,7 @@
 #include "binding.h"
 #include "coverage.h"
 #include "byte-order.h"
+#include "extend-table.h"
 #include "ct-zone.h"
 #include "encaps.h"
 #include "evpn-binding.h"
@@ -54,6 +55,7 @@
 #include "socket-util.h"
 #include "sset.h"
 #include "util.h"
+#include "vec.h"
 #include "vswitch-idl.h"
 #include "hmapx.h"
 #include "neighbor-of.h"
@@ -3594,7 +3596,9 @@ physical_consider_evpn_multicast(const struct 
evpn_multicast_group *mc_group,
 
 static void
 physical_consider_evpn_fdb(const struct evpn_fdb *fdb,
+                           struct ovn_extend_table *group_table,
                            struct ofpbuf *ofpacts, struct match *match,
+                           struct ds *group_ds,
                            struct ovn_desired_flow_table *flow_table)
 {
     /* Static FDB flow. */
@@ -3604,22 +3608,63 @@ physical_consider_evpn_fdb(const struct evpn_fdb *fdb,
     match_set_metadata(match, htonll(fdb->dp_key));
     match_set_dl_dst(match, fdb->mac);
 
-    put_load(fdb->binding_key, MFF_LOG_REMOTE_OUTPORT, 0, 32, ofpacts);
+    if (vector_len(&fdb->paths) > 1) {
+        /* ECMP: create a select group with one bucket per path.
+         * Each bucket loads its binding_key into
+         * MFF_LOG_REMOTE_OUTPORT. */
+        const struct mf_field *mf = mf_from_id(MFF_LOG_REMOTE_OUTPORT);
+
+        ds_clear(group_ds);
+        ds_put_cstr(group_ds, "type=select,selection_method=dp_hash");
+
+        for (size_t i = 0; i < vector_len(&fdb->paths); i++) {
+            struct evpn_fdb_path path =
+                vector_get(&fdb->paths, i, struct evpn_fdb_path);
+            ds_put_format(group_ds, ",bucket=bucket_id=%"PRIuSIZE","
+                          "weight:%"PRIu16",actions=load:%"PRIu32"->%s[0..%u]",
+                          i, path.weight, path.binding_key, mf->name,
+                          mf->n_bits - 1);
+        }
+
+        uint32_t group_id = ovn_extend_table_assign_id(group_table,
+                                                       ds_cstr(group_ds),
+                                                       fdb->flow_uuid);
+        if (group_id == EXT_TABLE_ID_INVALID) {
+            static struct vlog_rate_limit rl = VLOG_RATE_LIMIT_INIT(5, 1);
+            VLOG_WARN_RL(&rl, "Failed to allocate group ID for ECMP FDB "
+                         "entry "ETH_ADDR_FMT".", ETH_ADDR_ARGS(fdb->mac));
+            return;
+        }
+
+        struct ofpact_group *og = ofpact_put_GROUP(ofpacts);
+        og->group_id = group_id;
+    } else {
+        struct evpn_fdb_path path =
+            vector_get(&fdb->paths, 0, struct evpn_fdb_path);
+        put_load(path.binding_key, MFF_LOG_REMOTE_OUTPORT, 0, 32, ofpacts);
+    }
+
     ofctrl_add_flow(flow_table, OFTABLE_GET_REMOTE_FDB, 150,
                     fdb->flow_uuid.parts[0],
                     match, ofpacts, &fdb->flow_uuid);
 
-    /* Prevent dynamic learning if it's already known via static FDB. */
-    ofpbuf_clear(ofpacts);
-    match_init_catchall(match);
+    /* Prevent dynamic learning if it's already known via static FDB.
+     * Install one flow per path. */
+    for (size_t i = 0; i < vector_len(&fdb->paths); i++) {
+        struct evpn_fdb_path path =
+            vector_get(&fdb->paths, i, struct evpn_fdb_path);
 
-    match_set_metadata(match, htonll(fdb->dp_key));
-    match_set_reg(match, MFF_LOG_INPORT - MFF_REG0, fdb->binding_key);
-    match_set_dl_src(match, fdb->mac);
+        ofpbuf_clear(ofpacts);
+        match_init_catchall(match);
 
-    ofctrl_add_flow(flow_table, OFTABLE_LEARN_REMOTE_FDB, 150,
-                    fdb->flow_uuid.parts[0],
-                    match, ofpacts, &fdb->flow_uuid);
+        match_set_metadata(match, htonll(fdb->dp_key));
+        match_set_reg(match, MFF_LOG_INPORT - MFF_REG0, path.binding_key);
+        match_set_dl_src(match, fdb->mac);
+
+        ofctrl_add_flow(flow_table, OFTABLE_LEARN_REMOTE_FDB, 150,
+                        fdb->flow_uuid.parts[0],
+                        match, ofpacts, &fdb->flow_uuid);
+    }
 }
 
 static void
@@ -3677,11 +3722,16 @@ physical_eval_evpn_flows(const struct physical_ctx *ctx,
     }
     evpn_local_ip_map_destroy(&vni_ip_map);
 
+    struct ds group_ds = DS_EMPTY_INITIALIZER;
+
     const struct evpn_fdb *fdb;
     HMAP_FOR_EACH (fdb, hmap_node, ctx->evpn_fdbs) {
-        physical_consider_evpn_fdb(fdb, ofpacts, &match, flow_table);
+        physical_consider_evpn_fdb(fdb, ctx->group_table, ofpacts, &match,
+                                   &group_ds, flow_table);
     }
 
+    ds_destroy(&group_ds);
+
     const struct evpn_arp *arp;
     HMAP_FOR_EACH (arp, hmap_node, ctx->evpn_arps) {
         physical_consider_evpn_arp(ctx->local_datapaths, arp, flow_table);
@@ -3845,26 +3895,32 @@ physical_handle_evpn_binding_changes(
 
 void
 physical_handle_evpn_fdb_changes(struct ovn_desired_flow_table *flow_table,
+                                 struct ovn_extend_table *group_table,
                                  const struct hmapx *updated_fdbs,
                                  const struct uuidset *removed_fdbs)
 {
     struct ofpbuf ofpacts;
     ofpbuf_init(&ofpacts, 0);
     struct match match = MATCH_CATCHALL_INITIALIZER;
+    struct ds group_ds = DS_EMPTY_INITIALIZER;
 
     const struct hmapx_node *node;
     HMAPX_FOR_EACH (node, updated_fdbs) {
         const struct evpn_fdb *fdb = node->data;
 
         ofctrl_remove_flows(flow_table, &fdb->flow_uuid);
-        physical_consider_evpn_fdb(fdb, &ofpacts, &match, flow_table);
+        ovn_extend_table_remove_desired(group_table, &fdb->flow_uuid);
+        physical_consider_evpn_fdb(fdb, group_table, &ofpacts, &match,
+                                   &group_ds, flow_table);
     }
 
+    ds_destroy(&group_ds);
     ofpbuf_uninit(&ofpacts);
 
     const struct uuidset_node *uuidset_node;
     UUIDSET_FOR_EACH (uuidset_node, removed_fdbs) {
         ofctrl_remove_flows(flow_table, &uuidset_node->uuid);
+        ovn_extend_table_remove_desired(group_table, &uuidset_node->uuid);
     }
 }
 
diff --git a/controller/physical.h b/controller/physical.h
index c7a33bd02..b02caea88 100644
--- a/controller/physical.h
+++ b/controller/physical.h
@@ -30,6 +30,7 @@
 struct hmap;
 struct ovsdb_idl_index;
 struct ovsrec_bridge;
+struct ovn_extend_table;
 struct simap;
 struct sbrec_multicast_group_table;
 struct sbrec_port_binding_table;
@@ -76,6 +77,7 @@ struct physical_ctx {
     const struct hmap *evpn_multicast_groups;
     const struct hmap *evpn_fdbs;
     const struct hmap *evpn_arps;
+    struct ovn_extend_table *group_table;
 
     /* Set of port binding names that have been already reprocessed during
      * the I-P run. */
@@ -101,6 +103,7 @@ void physical_handle_evpn_binding_changes(
     const struct uuidset *removed_bindings,
     const struct uuidset *removed_multicast_groups);
 void physical_handle_evpn_fdb_changes(struct ovn_desired_flow_table *,
+                                      struct ovn_extend_table *group_table,
                                       const struct hmapx *updated_fdbs,
                                       const struct uuidset *removed_fdbs);
 void physical_handle_evpn_arp_changes(const struct hmap *local_datapaths,
diff --git a/tests/ovn-inc-proc-graph-dump.at b/tests/ovn-inc-proc-graph-dump.at
index 6b4d94835..22f08a063 100644
--- a/tests/ovn-inc-proc-graph-dump.at
+++ b/tests/ovn-inc-proc-graph-dump.at
@@ -418,7 +418,7 @@ digraph "Incremental-Processing-Engine" {
        evpn_fdb [[style=filled, shape=box, fillcolor=white, label="evpn_fdb"]];
        neighbor_exchange -> evpn_fdb [[label=""]];
        evpn_vtep_binding -> evpn_fdb [[label="evpn_fdb_vtep_binding_handler"]];
-       nexthop_exchange -> evpn_fdb [[label="engine_noop_handler"]];
+       nexthop_exchange -> evpn_fdb [[label=""]];
        evpn_arp [[style=filled, shape=box, fillcolor=white, label="evpn_arp"]];
        neighbor_exchange -> evpn_arp [[label=""]];
        evpn_vtep_binding -> evpn_arp [[label="evpn_arp_vtep_binding_handler"]];
diff --git a/tests/ovn-macros.at b/tests/ovn-macros.at
index 241453df9..2514014fb 100644
--- a/tests/ovn-macros.at
+++ b/tests/ovn-macros.at
@@ -1472,6 +1472,19 @@ netlink_if_index() {
     $ns_prefix ip -o link show dev $1 | awk -F: '{print $1}'
 }
 
+# Allocate a nexthop ID that is not currently in use by the system.
+# Prints the allocated ID to stdout.
+nexthop_alloc() {
+    for _nh_try in $(shuf -i 1000000-2000000 -n 10); do
+        if ! ip nexthop show id $_nh_try > /dev/null 2>&1; then
+            echo $_nh_try
+            return
+        fi
+    done
+    echo "Could not allocate a free nexthop ID"
+    AT_FAIL_IF([:])
+}
+
 OVS_END_SHELL_HELPERS
 
 m4_define([OVN_POPULATE_ARP], [AT_CHECK(ovn_populate_arp__, [0], [ignore])])
diff --git a/tests/system-ovn.at b/tests/system-ovn.at
index 582ed194b..0f71eb95d 100644
--- a/tests/system-ovn.at
+++ b/tests/system-ovn.at
@@ -18322,10 +18322,10 @@ check bridge fdb add f0:00:0f:16:10:60 dev 
$VXLAN_NAME dst 169.0.0.20 static ext
 check bridge fdb add f0:00:0f:16:10:70 dev $VXLAN_NAME dst 169.0.0.30 static 
extern_learn
 
 OVS_WAIT_FOR_OUTPUT_UNQUOTED([ovn-appctl evpn/vtep-fdb-list | cut -d',' -f2- | 
sort], [0], [dnl
- MAC: 00:00:00:00:00:01, vni: $vni, binding_key: 0x80000003, dp_key: $dp_key
- MAC: f0:00:0f:16:10:50, vni: $vni, binding_key: 0x80000001, dp_key: $dp_key
- MAC: f0:00:0f:16:10:60, vni: $vni, binding_key: 0x80000002, dp_key: $dp_key
- MAC: f0:00:0f:16:10:70, vni: $vni, binding_key: 0x80000003, dp_key: $dp_key
+ MAC: 00:00:00:00:00:01, vni: $vni, dp_key: $dp_key, paths: [[{key=0x80000003, 
weight=0}]]
+ MAC: f0:00:0f:16:10:50, vni: $vni, dp_key: $dp_key, paths: [[{key=0x80000001, 
weight=0}]]
+ MAC: f0:00:0f:16:10:60, vni: $vni, dp_key: $dp_key, paths: [[{key=0x80000002, 
weight=0}]]
+ MAC: f0:00:0f:16:10:70, vni: $vni, dp_key: $dp_key, paths: [[{key=0x80000003, 
weight=0}]]
 ])
 
 AT_CHECK_UNQUOTED([ovs-ofctl dump-flows br-int table=OFTABLE_GET_REMOTE_FDB | 
grep priority | \
@@ -18364,6 +18364,122 @@ check diff -q bindings_before bindings_after
 check diff -q mc_groups_before mc_groups_after
 check diff -q fdb_before fdb_after
 
+AS_BOX([L2 EVPN ECMP FDB (multi-homing)])
+
+# Allocate nexthop IDs that are not in use by the system.
+nh_id1=$(nexthop_alloc)
+nh_id2=$(nexthop_alloc)
+nh_grp_id=$(nexthop_alloc)
+
+# Create nexthop singletons and a weighted group for ECMP.
+check ip nexthop add id $nh_id1 via 169.0.0.10 fdb
+on_exit "ip nexthop del id $nh_id1"
+check ip nexthop add id $nh_id2 via 169.0.0.20 fdb
+on_exit "ip nexthop del id $nh_id2"
+check ip nexthop add id $nh_grp_id group $nh_id1,100/$nh_id2,200 fdb
+on_exit "ip nexthop del id $nh_grp_id"
+
+# Add an ECMP FDB entry using the nexthop group.
+check bridge fdb add f0:00:0f:16:10:90 dev $VXLAN_NAME nhid $nh_grp_id 
extern_learn
+
+# Verify the ECMP FDB entry has multiple paths with weights.
+OVS_WAIT_FOR_OUTPUT_UNQUOTED([ovn-appctl evpn/vtep-fdb-list | cut -d',' -f2- | 
sort], [0], [dnl
+ MAC: 00:00:00:00:00:01, vni: $vni, dp_key: $dp_key, paths: [[{key=0x80000003, 
weight=0}]]
+ MAC: f0:00:0f:16:10:50, vni: $vni, dp_key: $dp_key, paths: [[{key=0x80000001, 
weight=0}]]
+ MAC: f0:00:0f:16:10:60, vni: $vni, dp_key: $dp_key, paths: [[{key=0x80000002, 
weight=0}]]
+ MAC: f0:00:0f:16:10:70, vni: $vni, dp_key: $dp_key, paths: [[{key=0x80000003, 
weight=0}]]
+ MAC: f0:00:0f:16:10:90, vni: $vni, dp_key: $dp_key, paths: [[{key=0x80000001, 
weight=100}, {key=0x80000002, weight=200}]]
+])
+
+# Verify OpenFlow: ECMP entry uses a group action, single-path entries
+# use direct load.  Normalize the dynamic group ID for comparison.
+AT_CHECK_UNQUOTED(
+    [ovs-ofctl dump-flows br-int table=OFTABLE_GET_REMOTE_FDB | grep priority 
| \
+     awk '{print $[7], $[8]}' | sed 's/group:[[0-9]]*/group:GID/' | sort], 
[0], [dnl
+priority=0 actions=load:0->NXM_NX_REG1[[]]
+priority=150,metadata=0x$dp_key,dl_dst=00:00:00:00:00:01 
actions=load:0x80000003->NXM_NX_REG1[[]]
+priority=150,metadata=0x$dp_key,dl_dst=f0:00:0f:16:10:50 
actions=load:0x80000001->NXM_NX_REG1[[]]
+priority=150,metadata=0x$dp_key,dl_dst=f0:00:0f:16:10:60 
actions=load:0x80000002->NXM_NX_REG1[[]]
+priority=150,metadata=0x$dp_key,dl_dst=f0:00:0f:16:10:70 
actions=load:0x80000003->NXM_NX_REG1[[]]
+priority=150,metadata=0x$dp_key,dl_dst=f0:00:0f:16:10:90 actions=group:GID
+])
+
+# Verify the select group exists with weighted buckets.
+AT_CHECK_UNQUOTED(
+    [ovn-appctl group-table-list | \
+     sed 's/:[[[:space:]]]*[[0-9]]*$/: GID/'], [0], [dnl
+type=select,selection_method=dp_hash,bucket=bucket_id=0,weight:100,actions=load:2147483649->reg1[[0..31]],bucket=bucket_id=1,weight:200,actions=load:2147483650->reg1[[0..31]]:
 GID
+])
+
+# Verify learning suppression flows: two ECMP drop flows added.
+AT_CHECK_UNQUOTED(
+    [ovs-ofctl dump-flows br-int table=OFTABLE_LEARN_REMOTE_FDB | grep 
priority | \
+     awk '{print $[7], $[8]}' | strip_cookie | sort], [0], [dnl
+priority=100,reg14=0x80000001,metadata=0x$dp_key 
actions=learn(table=OFTABLE_GET_REMOTE_FDB,priority=150,delete_learned,OXM_OF_METADATA[[]],NXM_OF_ETH_DST[[]]=NXM_OF_ETH_SRC[[]],load:NXM_NX_REG14[[]]->NXM_NX_REG1[[]])
+priority=100,reg14=0x80000002,metadata=0x$dp_key 
actions=learn(table=OFTABLE_GET_REMOTE_FDB,priority=150,delete_learned,OXM_OF_METADATA[[]],NXM_OF_ETH_DST[[]]=NXM_OF_ETH_SRC[[]],load:NXM_NX_REG14[[]]->NXM_NX_REG1[[]])
+priority=100,reg14=0x80000003,metadata=0x$dp_key 
actions=learn(table=OFTABLE_GET_REMOTE_FDB,priority=150,delete_learned,OXM_OF_METADATA[[]],NXM_OF_ETH_DST[[]]=NXM_OF_ETH_SRC[[]],load:NXM_NX_REG14[[]]->NXM_NX_REG1[[]])
+priority=150,reg14=0x80000001,metadata=0x$dp_key,dl_src=f0:00:0f:16:10:50 
actions=drop
+priority=150,reg14=0x80000001,metadata=0x$dp_key,dl_src=f0:00:0f:16:10:90 
actions=drop
+priority=150,reg14=0x80000002,metadata=0x$dp_key,dl_src=f0:00:0f:16:10:60 
actions=drop
+priority=150,reg14=0x80000002,metadata=0x$dp_key,dl_src=f0:00:0f:16:10:90 
actions=drop
+priority=150,reg14=0x80000003,metadata=0x$dp_key,dl_src=00:00:00:00:00:01 
actions=drop
+priority=150,reg14=0x80000003,metadata=0x$dp_key,dl_src=f0:00:0f:16:10:70 
actions=drop
+])
+
+# Update the nexthop group: remove one member.
+check ip nexthop replace id $nh_grp_id group $nh_id1,100 fdb
+
+# Verify the entry reverts to single path.
+OVS_WAIT_FOR_OUTPUT_UNQUOTED([ovn-appctl evpn/vtep-fdb-list | cut -d',' -f2- | 
sort], [0], [dnl
+ MAC: 00:00:00:00:00:01, vni: $vni, dp_key: $dp_key, paths: [[{key=0x80000003, 
weight=0}]]
+ MAC: f0:00:0f:16:10:50, vni: $vni, dp_key: $dp_key, paths: [[{key=0x80000001, 
weight=0}]]
+ MAC: f0:00:0f:16:10:60, vni: $vni, dp_key: $dp_key, paths: [[{key=0x80000002, 
weight=0}]]
+ MAC: f0:00:0f:16:10:70, vni: $vni, dp_key: $dp_key, paths: [[{key=0x80000003, 
weight=0}]]
+ MAC: f0:00:0f:16:10:90, vni: $vni, dp_key: $dp_key, paths: [[{key=0x80000001, 
weight=100}]]
+])
+
+AT_CHECK_UNQUOTED(
+    [ovs-ofctl dump-flows br-int table=OFTABLE_GET_REMOTE_FDB | grep priority 
| \
+     awk '{print $[7], $[8]}' | sort], [0], [dnl
+priority=0 actions=load:0->NXM_NX_REG1[[]]
+priority=150,metadata=0x$dp_key,dl_dst=00:00:00:00:00:01 
actions=load:0x80000003->NXM_NX_REG1[[]]
+priority=150,metadata=0x$dp_key,dl_dst=f0:00:0f:16:10:50 
actions=load:0x80000001->NXM_NX_REG1[[]]
+priority=150,metadata=0x$dp_key,dl_dst=f0:00:0f:16:10:60 
actions=load:0x80000002->NXM_NX_REG1[[]]
+priority=150,metadata=0x$dp_key,dl_dst=f0:00:0f:16:10:70 
actions=load:0x80000003->NXM_NX_REG1[[]]
+priority=150,metadata=0x$dp_key,dl_dst=f0:00:0f:16:10:90 
actions=load:0x80000001->NXM_NX_REG1[[]]
+])
+
+AT_CHECK_UNQUOTED(
+    [ovs-ofctl dump-flows br-int table=OFTABLE_LEARN_REMOTE_FDB | grep 
priority | \
+     awk '{print $[7], $[8]}' | strip_cookie | sort], [0], [dnl
+priority=100,reg14=0x80000001,metadata=0x$dp_key 
actions=learn(table=OFTABLE_GET_REMOTE_FDB,priority=150,delete_learned,OXM_OF_METADATA[[]],NXM_OF_ETH_DST[[]]=NXM_OF_ETH_SRC[[]],load:NXM_NX_REG14[[]]->NXM_NX_REG1[[]])
+priority=100,reg14=0x80000002,metadata=0x$dp_key 
actions=learn(table=OFTABLE_GET_REMOTE_FDB,priority=150,delete_learned,OXM_OF_METADATA[[]],NXM_OF_ETH_DST[[]]=NXM_OF_ETH_SRC[[]],load:NXM_NX_REG14[[]]->NXM_NX_REG1[[]])
+priority=100,reg14=0x80000003,metadata=0x$dp_key 
actions=learn(table=OFTABLE_GET_REMOTE_FDB,priority=150,delete_learned,OXM_OF_METADATA[[]],NXM_OF_ETH_DST[[]]=NXM_OF_ETH_SRC[[]],load:NXM_NX_REG14[[]]->NXM_NX_REG1[[]])
+priority=150,reg14=0x80000001,metadata=0x$dp_key,dl_src=f0:00:0f:16:10:50 
actions=drop
+priority=150,reg14=0x80000001,metadata=0x$dp_key,dl_src=f0:00:0f:16:10:90 
actions=drop
+priority=150,reg14=0x80000002,metadata=0x$dp_key,dl_src=f0:00:0f:16:10:60 
actions=drop
+priority=150,reg14=0x80000003,metadata=0x$dp_key,dl_src=00:00:00:00:00:01 
actions=drop
+priority=150,reg14=0x80000003,metadata=0x$dp_key,dl_src=f0:00:0f:16:10:70 
actions=drop
+])
+
+# Check recompute stability while ECMP entry is still present.
+ovn-appctl evpn/vtep-fdb-list > fdb_ecmp_before
+check ovn-appctl inc-engine/recompute
+check ovn-nbctl --wait=hv sync
+ovn-appctl evpn/vtep-fdb-list > fdb_ecmp_after
+check diff -q fdb_ecmp_before fdb_ecmp_after
+
+# Remove the ECMP FDB entry.
+check bridge fdb del f0:00:0f:16:10:90 dev $VXLAN_NAME nhid $nh_grp_id
+
+# Verify the FDB entry is cleaned up.
+OVS_WAIT_FOR_OUTPUT_UNQUOTED([ovn-appctl evpn/vtep-fdb-list | cut -d',' -f2- | 
sort], [0], [dnl
+ MAC: 00:00:00:00:00:01, vni: $vni, dp_key: $dp_key, paths: [[{key=0x80000003, 
weight=0}]]
+ MAC: f0:00:0f:16:10:50, vni: $vni, dp_key: $dp_key, paths: [[{key=0x80000001, 
weight=0}]]
+ MAC: f0:00:0f:16:10:60, vni: $vni, dp_key: $dp_key, paths: [[{key=0x80000002, 
weight=0}]]
+ MAC: f0:00:0f:16:10:70, vni: $vni, dp_key: $dp_key, paths: [[{key=0x80000003, 
weight=0}]]
+])
+
 AS_BOX([L2 EVPN FDB advertising])
 
 check ovn-nbctl --wait=hv set logical_switch ls-evpn 
other_config:dynamic-routing-redistribute=fdb
@@ -18859,10 +18975,10 @@ check bridge fdb add f0:00:0f:16:10:60 dev 
$VXLAN_NAME dst 169::20 static extern
 check bridge fdb add f0:00:0f:16:10:70 dev $VXLAN_NAME dst 169::30 static 
extern_learn
 
 OVS_WAIT_FOR_OUTPUT_UNQUOTED([ovn-appctl evpn/vtep-fdb-list | cut -d',' -f2- | 
sort], [0], [dnl
- MAC: 00:00:00:00:00:01, vni: $vni, binding_key: 0x80000003, dp_key: $dp_key
- MAC: f0:00:0f:16:10:50, vni: $vni, binding_key: 0x80000001, dp_key: $dp_key
- MAC: f0:00:0f:16:10:60, vni: $vni, binding_key: 0x80000002, dp_key: $dp_key
- MAC: f0:00:0f:16:10:70, vni: $vni, binding_key: 0x80000003, dp_key: $dp_key
+ MAC: 00:00:00:00:00:01, vni: $vni, dp_key: $dp_key, paths: [[{key=0x80000003, 
weight=0}]]
+ MAC: f0:00:0f:16:10:50, vni: $vni, dp_key: $dp_key, paths: [[{key=0x80000001, 
weight=0}]]
+ MAC: f0:00:0f:16:10:60, vni: $vni, dp_key: $dp_key, paths: [[{key=0x80000002, 
weight=0}]]
+ MAC: f0:00:0f:16:10:70, vni: $vni, dp_key: $dp_key, paths: [[{key=0x80000003, 
weight=0}]]
 ])
 
 AT_CHECK_UNQUOTED([ovs-ofctl dump-flows br-int table=OFTABLE_GET_REMOTE_FDB | 
grep priority | \
@@ -19426,12 +19542,12 @@ check bridge fdb add f0:00:0f:16:10:70 dev 
$VXLAN_NAME dst 169.0.0.30 static ext
 check bridge fdb add f0:00:0f:16:10:80 dev $VXLAN_V6_NAME dst 169::30 static 
extern_learn
 
 OVS_WAIT_FOR_OUTPUT_UNQUOTED([ovn-appctl evpn/vtep-fdb-list | cut -d',' -f2- | 
sort], [0], [dnl
- MAC: 00:00:00:00:00:01, vni: $vni, binding_key: 0x80000004, dp_key: $dp_key
- MAC: 00:00:00:00:00:10, vni: $vni, binding_key: 0x80000005, dp_key: $dp_key
- MAC: f0:00:0f:16:10:50, vni: $vni, binding_key: 0x80000001, dp_key: $dp_key
- MAC: f0:00:0f:16:10:60, vni: $vni, binding_key: 0x80000003, dp_key: $dp_key
- MAC: f0:00:0f:16:10:70, vni: $vni, binding_key: 0x80000004, dp_key: $dp_key
- MAC: f0:00:0f:16:10:80, vni: $vni, binding_key: 0x80000005, dp_key: $dp_key
+ MAC: 00:00:00:00:00:01, vni: $vni, dp_key: $dp_key, paths: [[{key=0x80000004, 
weight=0}]]
+ MAC: 00:00:00:00:00:10, vni: $vni, dp_key: $dp_key, paths: [[{key=0x80000005, 
weight=0}]]
+ MAC: f0:00:0f:16:10:50, vni: $vni, dp_key: $dp_key, paths: [[{key=0x80000001, 
weight=0}]]
+ MAC: f0:00:0f:16:10:60, vni: $vni, dp_key: $dp_key, paths: [[{key=0x80000003, 
weight=0}]]
+ MAC: f0:00:0f:16:10:70, vni: $vni, dp_key: $dp_key, paths: [[{key=0x80000004, 
weight=0}]]
+ MAC: f0:00:0f:16:10:80, vni: $vni, dp_key: $dp_key, paths: [[{key=0x80000005, 
weight=0}]]
 ])
 
 AT_CHECK_UNQUOTED([ovs-ofctl dump-flows br-int table=OFTABLE_GET_REMOTE_FDB | 
grep priority | \
-- 
2.53.0

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

Reply via email to