diff mbox series

[ovs-dev,RFC] controller: Add veth peer autodiscovery for routing-protocol-redirect.

Message ID 20251219113258.101540-1-matteo.perin@canonical.com
State Changes Requested
Headers show
Series [ovs-dev,RFC] controller: Add veth peer autodiscovery for routing-protocol-redirect. | expand

Checks

Context Check Description
ovsrobot/apply-robot success apply and check: success
ovsrobot/github-robot-_Build_and_Test success github build: passed
ovsrobot/github-robot-_ovn-kubernetes fail github build: failed

Commit Message

Matteo Perin Dec. 19, 2025, 11:32 a.m. UTC
The OVN native route learning code added the LRP dynamic-routing-port-name
and accompanying dynamic-routing-port-mapping key in the local OVS table.

When the routing-protocol-redirect option is in use on an LRP, the need to
manually set the dynamic-routing-port-name and dynamic-routing-port-mapping
options can be removed if OVN can support looking up the veth pair bound to
the LSP referred to, when using veth pairs to connect routing daemons to OVN.

This commit introduces this capability: when the routing-protocol-redirect
option is configured on a LRP to redirect routing protocol traffic to a LSP,
and that LSP is bound to a veth interface, ovn-controller will now automatically
discover the peer interface name and uses it for dynamic route learning.

The discovery happens at port binding time in the route exchange code path.
To do this, the ethtool ioctl interface is leveraged to verify the bound
interface is a veth and, in that case, retrieve the peer ifindex and relative
iface name.
This means that the discovery feature will only be available on Linux platforms.
Also, there is a fallback to learning from all interfaces (base case) if
discovery fails for any reason.

This will simplify dynamic routing deployments by automating the interface
mapping configuration that was previously required for veth-based routing
daemon integrations.

Signed-off-by: Matteo Perin <matteo.perin@canonical.com>
---
First of all, sorry everyone for the spam of emails I sent to forward this
patch, since this is my first contribution attempt I am still finding my
way around the project patch proposal pipeline.

This addition to the OVN controller is an effort to remove the need of
additional configuration when using the routing-protocol-redirect with
one of the most common network setups used along routing daemons.

The veth peer discovery is currently made thought an ioctl to ethtool and
this, unfortunately, makes this added feature platfom-dependent.
As an alternative, I also though about moving the discovery part to OVS
(this will not remove the linux kernel dependency, but it will make it
part of the already in-place OVS linux netdev layer).
I did not go through with this idea since it seemed to require moving too
many things around and adding space to store peer information to the
Interface table in OVSDB, and this seemed way too overkill for the limited
scope of this feature.

By my initial investigation, the refactoring using this approach would require:
- Extending OVS rtnetlink support to parse IFLA_LINK attribute (containing
veth peer ifindex info).
- Exposing it via OVSDB Interface:external_ids:peer_ifindex, or similar
- OVN controller then reads peer info from OVS table and calls if_indextoname()
to get interface name.

Would this (more "disruptive") technique be more preferable than having the
discovery management in a very specific path in the OVN controller? If so,
I can rework the patch taking into account your suggestions.

 controller/ovn-controller.8.xml |   8 ++
 controller/route.c              | 125 +++++++++++++++++++++++++++++-
 northd/northd.c                 |   5 ++
 ovn-nb.xml                      |  21 +++++
 tests/system-ovn.at             | 132 ++++++++++++++++++++++++++++++++
 5 files changed, 288 insertions(+), 3 deletions(-)

Comments

Ales Musil Jan. 19, 2026, 7:04 a.m. UTC | #1
On Fri, Dec 19, 2025 at 12:33 PM Matteo Perin via dev <
ovs-dev@openvswitch.org> wrote:

> The OVN native route learning code added the LRP dynamic-routing-port-name
> and accompanying dynamic-routing-port-mapping key in the local OVS table.
>
> When the routing-protocol-redirect option is in use on an LRP, the need to
> manually set the dynamic-routing-port-name and dynamic-routing-port-mapping
> options can be removed if OVN can support looking up the veth pair bound to
> the LSP referred to, when using veth pairs to connect routing daemons to
> OVN.
>
> This commit introduces this capability: when the routing-protocol-redirect
> option is configured on a LRP to redirect routing protocol traffic to a
> LSP,
> and that LSP is bound to a veth interface, ovn-controller will now
> automatically
> discover the peer interface name and uses it for dynamic route learning.
>
> The discovery happens at port binding time in the route exchange code path.
> To do this, the ethtool ioctl interface is leveraged to verify the bound
> interface is a veth and, in that case, retrieve the peer ifindex and
> relative
> iface name.
> This means that the discovery feature will only be available on Linux
> platforms.
> Also, there is a fallback to learning from all interfaces (base case) if
> discovery fails for any reason.
>
> This will simplify dynamic routing deployments by automating the interface
> mapping configuration that was previously required for veth-based routing
> daemon integrations.
>
> Signed-off-by: Matteo Perin <matteo.perin@canonical.com>
> ---
> First of all, sorry everyone for the spam of emails I sent to forward this
> patch, since this is my first contribution attempt I am still finding my
> way around the project patch proposal pipeline.
>
> This addition to the OVN controller is an effort to remove the need of
> additional configuration when using the routing-protocol-redirect with
> one of the most common network setups used along routing daemons.
>
> The veth peer discovery is currently made thought an ioctl to ethtool and
> this, unfortunately, makes this added feature platfom-dependent.
> As an alternative, I also though about moving the discovery part to OVS
> (this will not remove the linux kernel dependency, but it will make it
> part of the already in-place OVS linux netdev layer).
> I did not go through with this idea since it seemed to require moving too
> many things around and adding space to store peer information to the
> Interface table in OVSDB, and this seemed way too overkill for the limited
> scope of this feature.
>
> By my initial investigation, the refactoring using this approach would
> require:
> - Extending OVS rtnetlink support to parse IFLA_LINK attribute (containing
> veth peer ifindex info).
> - Exposing it via OVSDB Interface:external_ids:peer_ifindex, or similar
> - OVN controller then reads peer info from OVS table and calls
> if_indextoname()
> to get interface name.
>
> Would this (more "disruptive") technique be more preferable than having the
> discovery management in a very specific path in the OVN controller? If so,
> I can rework the patch taking into account your suggestions.
>

Hello Matteo,

thank you for the patch. I'm sorry I didn't reply earlier. To me it seems
like it would be better to utilize what ovs already has. If you take a look
at the way how "status" column is populated, we already call ethtool
there so it would be that hard to call it twice once with "ETHTOOL_GDRVINFO"
which ovs already does, then with "ETHTOOL_GSTATS". Then we could
add the "peer_ifindex" into the "status". This is part of
"netdev_linux_get_status()".
Having that we could easily check the status column, does that sound
reasonable
to you?

One note about "ETHTOOL_GSTATS" the hardcoded value 21 for the array
is not correct. It should be allocated to whatever the "ETHTOOL_GDRVINFO"
returns in "n_stats".

Ccing Ilya if he has any objections or other idea how to extend ovs
capabilities.


>  controller/ovn-controller.8.xml |   8 ++
>  controller/route.c              | 125 +++++++++++++++++++++++++++++-
>  northd/northd.c                 |   5 ++
>  ovn-nb.xml                      |  21 +++++
>  tests/system-ovn.at             | 132 ++++++++++++++++++++++++++++++++
>  5 files changed, 288 insertions(+), 3 deletions(-)
>
> diff --git a/controller/ovn-controller.8.xml
> b/controller/ovn-controller.8.xml
> index dfc7cc217..92ff5b980 100644
> --- a/controller/ovn-controller.8.xml
> +++ b/controller/ovn-controller.8.xml
> @@ -414,6 +414,14 @@
>            dynamic-routing-port-name option on Logical_Router_Ports.
>            See the <code>ovn-nb</code>(5) for more details.
>          </p>
> +
> +        <p>
> +          Note: When using the <code>routing-protocol-redirect</code>
> option
> +          with veth pairs on Linux systems, this mapping may not be
> necessary
> +          as <code>ovn-controller</code> will automatically discover the
> veth
> +          peer interface name. See the
> <code>routing-protocol-redirect</code>
> +          option documentation in <code>ovn-nb</code>(5) for details.
> +        </p>
>        </dd>
>
>        <dt><code>external_ids:ovn-cleanup-on-exit</code></dt>
> diff --git a/controller/route.c b/controller/route.c
> index ecddd0497..fe141acb7 100644
> --- a/controller/route.c
> +++ b/controller/route.c
> @@ -19,6 +19,11 @@
>
>  #include <net/if.h>
>
> +#ifdef __linux__
> +#include <linux/ethtool.h>
> +#include <linux/sockios.h>
> +#endif
> +
>  #include "vswitch-idl.h"
>  #include "openvswitch/hmap.h"
>  #include "openvswitch/vlog.h"
> @@ -30,6 +35,7 @@
>  #include "ha-chassis.h"
>  #include "local_data.h"
>  #include "route.h"
> +#include "socket-util.h"
>
>  #include "route-table.h"
>
> @@ -38,6 +44,81 @@ VLOG_DEFINE_THIS_MODULE(exchange);
>  #define PRIORITY_DEFAULT 1000
>  #define PRIORITY_LOCAL_BOUND 100
>
> +#ifdef __linux__
> +/* Discover the veth peer interface name for a given interface.
> + * Uses ethtool ioctl to verify the device is a veth and get peer_ifindex,
> + * then if_indextoname to get the peer interface name.
> + * Returns the peer interface name, or NULL if not found or on error.
> + * Caller must free the returned string. */
> +static char *
> +find_veth_peer(const char *ifname)
> +{
> +    struct ifreq ifr;
> +    struct ethtool_drvinfo drvinfo;
> +    int peer_ifindex;
> +    char *peer_name = NULL;
> +    int error;
> +
> +    if (!ifname) {
> +        return NULL;
> +    }
> +
> +    /* Verify device is a veth */
> +    memset(&ifr, 0, sizeof ifr);
> +    memset(&drvinfo, 0, sizeof drvinfo);
> +    ovs_strzcpy(ifr.ifr_name, ifname, sizeof ifr.ifr_name);
> +    drvinfo.cmd = ETHTOOL_GDRVINFO;
> +    ifr.ifr_data = (void *)&drvinfo;
> +
> +    error = af_inet_ioctl(SIOCETHTOOL, &ifr);
> +    if (error) {
> +        return NULL;
> +    }
> +    if (strcmp(drvinfo.driver, "veth") != 0) {
> +        return NULL;
> +    }
> +
> +    /* Get peer_ifindex from ethtool stats. */
> +    struct {
> +        uint32_t cmd;
> +        uint32_t n_stats;
> +        uint64_t data[21];
> +    } req;
> +
> +    memset(&req, 0, sizeof req);
> +    req.cmd = ETHTOOL_GSTATS;
> +    req.n_stats = 1;
> +    ifr.ifr_data = (void *)&req;
> +
> +    error = af_inet_ioctl(SIOCETHTOOL, &ifr);
> +    if (error) {
> +        return NULL;
> +    }
> +
> +    /* The kernel writes the peer_ifindex into req.data[0] */
> +    peer_ifindex = (int) req.data[0];
> +    if (peer_ifindex <= 0) {
> +        return NULL;
> +    }
> +
> +    /* Convert peer ifindex to interface name */
> +    char peer_ifname[IF_NAMESIZE];
> +    if (!if_indextoname(peer_ifindex, peer_ifname)) {
> +        return NULL;
> +    }
> +
> +    peer_name = xstrdup(peer_ifname);
> +    return peer_name;
> +}
> +#else
> +/* Non-Linux platforms do not support veth peer discovery */
> +static char *
> +find_veth_peer(const char *ifname OVS_UNUSED)
> +{
> +    return NULL;
> +}
> +#endif
> +
>  static bool
>  route_exchange_relevant_port(const struct sbrec_port_binding *pb)
>  {
> @@ -276,9 +357,47 @@ route_run(struct route_ctx_in *r_ctx_in,
>              }
>
>              if (!port_name) {
> -                /* No port-name set, so we learn routes from all ports. */
> -                smap_add_nocopy(&ad->bound_ports,
> -                                xstrdup(local_peer->logical_port), NULL);
> +                /* No explicit port-name set. Check if routing-protocol-
> +                 * redirect is configured and try to auto-discover the
> veth
> +                 * peer interface. */
> +                const char *redirect_port = smap_get(&repb->options,
> +
> "routing-protocol-redirect");
> +                if (redirect_port) {
> +                    const char *ifname = ifname_from_port_name(
> +                        &port_mapping, r_ctx_in->local_bindings,
> +                        r_ctx_in->chassis, redirect_port);
> +                    if (ifname) {
> +                        char *peer_iface = find_veth_peer(ifname);
> +                        if (peer_iface) {
> +                            static struct vlog_rate_limit rl =
> +                                VLOG_RATE_LIMIT_INIT(5, 20);
> +                            VLOG_INFO_RL(&rl, "Auto-discovered veth peer
> '%s' "
> +                                         "for port '%s' (bound to '%s')",
> +                                         peer_iface, redirect_port,
> ifname);
> +                            smap_add(&ad->bound_ports,
> +                                     local_peer->logical_port,
> peer_iface);
> +                            free(peer_iface);
> +                        } else {
> +                            /* No veth peer found, fall back to learning
> from
> +                             * all ports on this LRP. */
> +                            static struct vlog_rate_limit rl =
> +                                VLOG_RATE_LIMIT_INIT(5, 20);
> +                            VLOG_INFO_RL(&rl, "Cannot auto-discover veth "
> +                                         "peer for port '%s' (bound to "
> +                                         "'%s'), falling back to learning
> "
> +                                         "routes from all ports",
> +                                         redirect_port, ifname);
> +                            smap_add_nocopy(&ad->bound_ports,
> +
> xstrdup(local_peer->logical_port),
> +                                            NULL);
> +                        }
> +                        sset_add(r_ctx_out->filtered_ports,
> redirect_port);
> +                    }
> +                } else {
> +                    /* No port-name set, so we learn routes from all
> ports. */
> +                    smap_add_nocopy(&ad->bound_ports,
> +                                    xstrdup(local_peer->logical_port),
> NULL);
> +                }
>              } else {
>                  /* If a port_name is set the we filter for the name as
> set in
>                   * the port-mapping or the interface name of the local
> diff --git a/northd/northd.c b/northd/northd.c
> index c3c0780a3..962f0bd68 100644
> --- a/northd/northd.c
> +++ b/northd/northd.c
> @@ -3902,6 +3902,11 @@ sync_pb_for_lrp(struct ovn_port *op,
>              if (portname) {
>                  smap_add(&new, "dynamic-routing-port-name", portname);
>              }
> +            const char *redirect_port = smap_get(&op->nbrp->options,
> +
>  "routing-protocol-redirect");
> +            if (redirect_port) {
> +                smap_add(&new, "routing-protocol-redirect",
> redirect_port);
> +            }
>          }
>
>          const char *redistribute_local_only_name =
> diff --git a/ovn-nb.xml b/ovn-nb.xml
> index a1edd8d35..78834a850 100644
> --- a/ovn-nb.xml
> +++ b/ovn-nb.xml
> @@ -4265,6 +4265,27 @@ or
>            Logical Switch and act as if they were listening on Logical
> Router
>            Port's IP addresses.
>          </p>
> +
> +        <p>
> +          When used with dynamic routing (when <ref column="options"
> +          key="dynamic-routing" table="Logical_Router"/> is set to
> +          <code>true</code>), if the specified Logical Switch Port is
> bound
> +          locally and connected to a veth pair,
> <code>ovn-controller</code>
> +          will try to automatically discover the peer interface name and
> use
> +          it for route learning. This removes the need to manually
> configure
> +          <ref column="options" key="dynamic-routing-port-name"/> and/or
> +          <ref key="dynamic-routing-port-mapping" table="Open_vSwitch"
> +          column="external_ids" db="Open_vSwitch"/> for veth-based routing
> +          daemon integrations.
> +        </p>
> +
> +        <p>
> +          The auto-discovery feature uses the ethtool interface to
> identify
> +          the veth peer and is only available on Linux systems.
> +          If the bound interface is not a veth device or if auto-discovery
> +          fails for any reason, the system will fallback to learning
> routes
> +          from all interfaces on the Logical Router Port.
> +        </p>
>        </column>
>
>        <column name="options" key="routing-protocols" type='{"type":
> "string"}'>
> diff --git a/tests/system-ovn.at b/tests/system-ovn.at
> index ec3b3735f..add4938c3 100644
> --- a/tests/system-ovn.at
> +++ b/tests/system-ovn.at
> @@ -19590,6 +19590,138 @@ OVS_TRAFFIC_VSWITCHD_STOP(["/failed to query
> port patch-.*/d
>  /Failed to acquire.*/d
>  /connection dropped.*/d
>  /Couldn't parse IPv6 prefix nexthop.*/d"])
> +
> +AT_CLEANUP
> +])
> +
> +OVN_FOR_EACH_NORTHD([
> +AT_SETUP([dynamic-routing - routing-protocol-redirect auto-discovery])
> +AT_SKIP_IF([test "$(uname -s)" != "Linux"])
> +
> +vni=1337
> +VRF_RESERVE([$vni])
> +
> +# This test validates that automatic veth peer discovery works with
> +# routing-protocol-redirect option and that routes can be learned.
> +# Note: This feature is Linux-only as it relies on ethtool to query
> +# veth peers.
> +#
> +# Topology:
> +#  +----------+
> +#  |    lr    | (learns routes from VRF 1337)
> +#  +----+-----+
> +#       |
> +#  +----+----+
> +#  |   ls    |
> +#  +----+----+
> +#       |
> +#  +----+------+     +----------+
> +#  | bgp-lsp   |-----| bgp-peer | (veth pair - auto-discovered, in VRF
> 1337)
> +#  +-----------+     +----------+
> +
> +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
> +
> +# Create VRF for route learning
> +OVS_WAIT_WHILE([ip link | grep -q ovnvrf$vni:.*UP])
> +check ip link add vrf-$vni type vrf table $vni
> +on_exit "ip link del vrf-$vni"
> +check ip link set vrf-$vni up
> +
> +# Create logical router with routing-protocol-redirect
> +check ovn-nbctl \
> +    -- lr-add lr \
> +      -- set Logical_Router lr \
> +          options:chassis=hv1 \
> +          options:dynamic-routing=true \
> +          options:dynamic-routing-vrf-id=$vni \
> +          options:dynamic-routing-maintain-vrf=false \
> +    -- lrp-add lr lr-ext 00:00:00:01:00:10 1.1.1.1/24 \
> +      -- lrp-set-options lr-ext dynamic-routing=true \
> +                                routing-protocol-redirect=bgp-lsp \
> +    -- ls-add ls \
> +      -- lsp-add-router-port ls ls-lr-ext lr-ext \
> +      -- lsp-add ls bgp-lsp \
> +        -- lsp-set-options bgp-lsp dynamic-routing=true \
> +        -- lsp-set-addresses bgp-lsp unknown
> +
> +# Create veth pair: one end bound to OVN (bgp-ovn), other end for BGP
> daemon (bgp-peer)
> +# The auto-discovery will find bgp-peer from bgp-ovn
> +# Delete if already exists to avoid "File exists" errors
> +ip link del bgp-ovn 2>/dev/null || true
> +check ip link add bgp-ovn type veth peer name bgp-peer
> +on_exit "ip link del bgp-ovn 2>/dev/null || true"
> +check ip link set bgp-ovn up
> +check ip link set bgp-peer master vrf-$vni
> +check ip link set bgp-peer up
> +check ip addr add 1.1.1.100/24 dev bgp-peer
> +
> +# Bind bgp-ovn to OVN
> +check ovs-vsctl add-port br-int bgp-ovn \
> +    -- set interface bgp-ovn external_ids:iface-id=bgp-lsp
> +
> +wait_for_ports_up bgp-lsp
> +
> +# Give ovn-controller time to process and discover the veth peer
> +sleep 3
> +
> +# Verify veth peer auto-discovery happened
> +AT_CHECK([grep -q "Auto-discovered veth peer 'bgp-peer' for port
> 'bgp-lsp'" ovn-controller.log], [0])
> +
> +# Add a route to the VRF (simulating BGP learning a route via bgp-peer)
> +AT_CHECK([ip route add 10.10.1.1 via 1.1.1.2 vrf vrf-$vni proto zebra])
> +
> +# Verify learned route appears in SB database
> +OVS_WAIT_UNTIL([ovn-sbctl list Learned_Route | grep ip_prefix | grep -Fe
> 10.10.1.1])
> +
> +# Add a second route
> +AT_CHECK([ip route add 10.10.2.1 via 1.1.1.2 vrf vrf-$vni proto zebra])
> +
> +# Verify both routes appear in SB database
> +OVS_WAIT_FOR_OUTPUT([ovn-sbctl list Learned_Route | grep ip_prefix |
> sort], [0], [dnl
> +ip_prefix           : "10.10.1.1"
> +ip_prefix           : "10.10.2.1"
> +])
> +
> +# Remove one route
> +AT_CHECK([ip route del 10.10.2.1 via 1.1.1.2 vrf vrf-$vni])
> +
> +# Verify only one route remains
> +OVS_WAIT_FOR_OUTPUT([ovn-sbctl list Learned_Route | grep ip_prefix |
> sort], [0], [dnl
> +ip_prefix           : "10.10.1.1"
> +])
> +
> +# Remove second route
> +AT_CHECK([ip route del 10.10.1.1 via 1.1.1.2 vrf vrf-$vni])
> +
> +# Verify all routes removed
> +OVS_WAIT_FOR_OUTPUT([ovn-sbctl list Learned_Route | grep ip_prefix |
> sort], [0], [dnl
> +])
> +
> +# Delete logical objects before cleanup
> +check ovn-nbctl --wait=hv ls-del ls
> +check ovn-nbctl --wait=hv lr-del lr
> +
> +OVN_CLEANUP_CONTROLLER([hv1])
> +
> +OVN_CLEANUP_NORTHD
> +
> +as
> +OVS_TRAFFIC_VSWITCHD_STOP(["/.*error receiving.*/d
> +/failed to query port patch-.*/d
> +/.*terminating with signal 15.*/d
> +/could not open network device bgp-ovn.*/d"])
> +
>  AT_CLEANUP
>  ])
>
> --
> 2.43.0
>
> _______________________________________________
> dev mailing list
> dev@openvswitch.org
> https://mail.openvswitch.org/mailman/listinfo/ovs-dev
>
>
Regards,
Ales
Ilya Maximets Jan. 20, 2026, 8:08 p.m. UTC | #2
On 1/19/26 8:04 AM, Ales Musil wrote:
> 
> 
> On Fri, Dec 19, 2025 at 12:33 PM Matteo Perin via dev <ovs-dev@openvswitch.org <mailto:ovs-dev@openvswitch.org>> wrote:
> 
>     The OVN native route learning code added the LRP dynamic-routing-port-name
>     and accompanying dynamic-routing-port-mapping key in the local OVS table.
> 
>     When the routing-protocol-redirect option is in use on an LRP, the need to
>     manually set the dynamic-routing-port-name and dynamic-routing-port-mapping
>     options can be removed if OVN can support looking up the veth pair bound to
>     the LSP referred to, when using veth pairs to connect routing daemons to OVN.
> 
>     This commit introduces this capability: when the routing-protocol-redirect
>     option is configured on a LRP to redirect routing protocol traffic to a LSP,
>     and that LSP is bound to a veth interface, ovn-controller will now automatically
>     discover the peer interface name and uses it for dynamic route learning.
> 
>     The discovery happens at port binding time in the route exchange code path.
>     To do this, the ethtool ioctl interface is leveraged to verify the bound
>     interface is a veth and, in that case, retrieve the peer ifindex and relative
>     iface name.
>     This means that the discovery feature will only be available on Linux platforms.
>     Also, there is a fallback to learning from all interfaces (base case) if
>     discovery fails for any reason.
> 
>     This will simplify dynamic routing deployments by automating the interface
>     mapping configuration that was previously required for veth-based routing
>     daemon integrations.
> 
>     Signed-off-by: Matteo Perin <matteo.perin@canonical.com <mailto:matteo.perin@canonical.com>>
>     ---
>     First of all, sorry everyone for the spam of emails I sent to forward this
>     patch, since this is my first contribution attempt I am still finding my
>     way around the project patch proposal pipeline.
> 
>     This addition to the OVN controller is an effort to remove the need of
>     additional configuration when using the routing-protocol-redirect with
>     one of the most common network setups used along routing daemons.
> 
>     The veth peer discovery is currently made thought an ioctl to ethtool and
>     this, unfortunately, makes this added feature platfom-dependent.
>     As an alternative, I also though about moving the discovery part to OVS
>     (this will not remove the linux kernel dependency, but it will make it
>     part of the already in-place OVS linux netdev layer).
>     I did not go through with this idea since it seemed to require moving too
>     many things around and adding space to store peer information to the
>     Interface table in OVSDB, and this seemed way too overkill for the limited
>     scope of this feature.
> 
>     By my initial investigation, the refactoring using this approach would require:
>     - Extending OVS rtnetlink support to parse IFLA_LINK attribute (containing
>     veth peer ifindex info).
>     - Exposing it via OVSDB Interface:external_ids:peer_ifindex, or similar
>     - OVN controller then reads peer info from OVS table and calls if_indextoname()
>     to get interface name.
> 
>     Would this (more "disruptive") technique be more preferable than having the
>     discovery management in a very specific path in the OVN controller? If so,
>     I can rework the patch taking into account your suggestions.
> 
> 
> Hello Matteo,
> 
> thank you for the patch. I'm sorry I didn't reply earlier. To me it seems
> like it would be better to utilize what ovs already has. If you take a look
> at the way how "status" column is populated, we already call ethtool
> there so it would be that hard to call it twice once with "ETHTOOL_GDRVINFO"
> which ovs already does, then with "ETHTOOL_GSTATS". Then we could
> add the "peer_ifindex" into the "status". This is part of "netdev_linux_get_status()".
> Having that we could easily check the status column, does that sound reasonable
> to you?
> 
> One note about "ETHTOOL_GSTATS" the hardcoded value 21 for the array
> is not correct. It should be allocated to whatever the "ETHTOOL_GDRVINFO"
> returns in "n_stats".
> 
> Ccing Ilya if he has any objections or other idea how to extend ovs capabilities.

We can do that from OVS side.  But we need to be careful to avoid issuing
the commands unnecessarily, i.e. we need to cache the results to avoid
blocking inside the kernel when we have a lot of ports.  But it's not fully
clear to me, how we would detect that the value changed in the kernel.
This can potentially happen when the peer is moved in and out a different
namespace.  It's hard to keep this information up to date.  Any ideas?

Best regards, Ilya Maximets.
Ilya Maximets Jan. 20, 2026, 8:17 p.m. UTC | #3
On 1/20/26 9:08 PM, Ilya Maximets wrote:
> On 1/19/26 8:04 AM, Ales Musil wrote:
>>
>>
>> On Fri, Dec 19, 2025 at 12:33 PM Matteo Perin via dev <ovs-dev@openvswitch.org <mailto:ovs-dev@openvswitch.org>> wrote:
>>
>>     The OVN native route learning code added the LRP dynamic-routing-port-name
>>     and accompanying dynamic-routing-port-mapping key in the local OVS table.
>>
>>     When the routing-protocol-redirect option is in use on an LRP, the need to
>>     manually set the dynamic-routing-port-name and dynamic-routing-port-mapping
>>     options can be removed if OVN can support looking up the veth pair bound to
>>     the LSP referred to, when using veth pairs to connect routing daemons to OVN.
>>
>>     This commit introduces this capability: when the routing-protocol-redirect
>>     option is configured on a LRP to redirect routing protocol traffic to a LSP,
>>     and that LSP is bound to a veth interface, ovn-controller will now automatically
>>     discover the peer interface name and uses it for dynamic route learning.
>>
>>     The discovery happens at port binding time in the route exchange code path.
>>     To do this, the ethtool ioctl interface is leveraged to verify the bound
>>     interface is a veth and, in that case, retrieve the peer ifindex and relative
>>     iface name.
>>     This means that the discovery feature will only be available on Linux platforms.
>>     Also, there is a fallback to learning from all interfaces (base case) if
>>     discovery fails for any reason.
>>
>>     This will simplify dynamic routing deployments by automating the interface
>>     mapping configuration that was previously required for veth-based routing
>>     daemon integrations.
>>
>>     Signed-off-by: Matteo Perin <matteo.perin@canonical.com <mailto:matteo.perin@canonical.com>>
>>     ---
>>     First of all, sorry everyone for the spam of emails I sent to forward this
>>     patch, since this is my first contribution attempt I am still finding my
>>     way around the project patch proposal pipeline.
>>
>>     This addition to the OVN controller is an effort to remove the need of
>>     additional configuration when using the routing-protocol-redirect with
>>     one of the most common network setups used along routing daemons.
>>
>>     The veth peer discovery is currently made thought an ioctl to ethtool and
>>     this, unfortunately, makes this added feature platfom-dependent.
>>     As an alternative, I also though about moving the discovery part to OVS
>>     (this will not remove the linux kernel dependency, but it will make it
>>     part of the already in-place OVS linux netdev layer).
>>     I did not go through with this idea since it seemed to require moving too
>>     many things around and adding space to store peer information to the
>>     Interface table in OVSDB, and this seemed way too overkill for the limited
>>     scope of this feature.
>>
>>     By my initial investigation, the refactoring using this approach would require:
>>     - Extending OVS rtnetlink support to parse IFLA_LINK attribute (containing
>>     veth peer ifindex info).
>>     - Exposing it via OVSDB Interface:external_ids:peer_ifindex, or similar
>>     - OVN controller then reads peer info from OVS table and calls if_indextoname()
>>     to get interface name.
>>
>>     Would this (more "disruptive") technique be more preferable than having the
>>     discovery management in a very specific path in the OVN controller? If so,
>>     I can rework the patch taking into account your suggestions.
>>
>>
>> Hello Matteo,
>>
>> thank you for the patch. I'm sorry I didn't reply earlier. To me it seems
>> like it would be better to utilize what ovs already has. If you take a look
>> at the way how "status" column is populated, we already call ethtool
>> there so it would be that hard to call it twice once with "ETHTOOL_GDRVINFO"
>> which ovs already does, then with "ETHTOOL_GSTATS". Then we could
>> add the "peer_ifindex" into the "status". This is part of "netdev_linux_get_status()".
>> Having that we could easily check the status column, does that sound reasonable
>> to you?
>>
>> One note about "ETHTOOL_GSTATS" the hardcoded value 21 for the array
>> is not correct. It should be allocated to whatever the "ETHTOOL_GDRVINFO"
>> returns in "n_stats".
>>
>> Ccing Ilya if he has any objections or other idea how to extend ovs capabilities.
> 
> We can do that from OVS side.  But we need to be careful to avoid issuing
> the commands unnecessarily, i.e. we need to cache the results to avoid
> blocking inside the kernel when we have a lot of ports.  But it's not fully
> clear to me, how we would detect that the value changed in the kernel.
> This can potentially happen when the peer is moved in and out a different
> namespace.  It's hard to keep this information up to date.  Any ideas?

Ah, nevermind.  ifindex doesn't change, so we can just check it once.  But
we need to make sure that we're not reporting a stale value if the interface
is removed and then a new one is added with the same name.

Best regards, Ilya Maximets.
Matteo Perin Jan. 21, 2026, 5:08 p.m. UTC | #4
Thank you Ales and Ilya for your reviews!

I am in the process of reworking this PR to use the OVS infrastructure
already in-place.

I was almost ready to post the patch for the OVS side but Ilya raised a
good concern today. So, I integrated the situation illustrated (a veth pair
is removed and then recreated with the same name) in a test case and, in
fact, it causes issues with stale values.
I am trying to work out how to tackle this situation. With kernel datapath
is trivial, but in userspace we do not receive RTM_NEWLINK/RTM_DELLINK to
invalidate VALID_DRVINFO (and, consequently, retrigger fetching the peer
ifindex) when the device is recreated.
I tried various solutions but the only one that worked was checking if the
veth ifindex changes. Although calling get_ifindex() is an ioctl and not a
call to ethtool, it still does introduce scalability problems, so it is
best to avoid it.

I want to try and see if I find some other way to handle this, but I will
still post the basic code as an RFC tomorrow if I do not get any
worthwhile ideas, so we can discuss it with more context.

Thank you for all the help.

Best Regards,
Matteo

On Tue, 20 Jan 2026 at 21:17, Ilya Maximets <i.maximets@ovn.org> wrote:

> On 1/20/26 9:08 PM, Ilya Maximets wrote:
> > On 1/19/26 8:04 AM, Ales Musil wrote:
> >>
> >>
> >> On Fri, Dec 19, 2025 at 12:33 PM Matteo Perin via dev <
> ovs-dev@openvswitch.org <mailto:ovs-dev@openvswitch.org>> wrote:
> >>
> >>     The OVN native route learning code added the LRP
> dynamic-routing-port-name
> >>     and accompanying dynamic-routing-port-mapping key in the local OVS
> table.
> >>
> >>     When the routing-protocol-redirect option is in use on an LRP, the
> need to
> >>     manually set the dynamic-routing-port-name and
> dynamic-routing-port-mapping
> >>     options can be removed if OVN can support looking up the veth pair
> bound to
> >>     the LSP referred to, when using veth pairs to connect routing
> daemons to OVN.
> >>
> >>     This commit introduces this capability: when the
> routing-protocol-redirect
> >>     option is configured on a LRP to redirect routing protocol traffic
> to a LSP,
> >>     and that LSP is bound to a veth interface, ovn-controller will now
> automatically
> >>     discover the peer interface name and uses it for dynamic route
> learning.
> >>
> >>     The discovery happens at port binding time in the route exchange
> code path.
> >>     To do this, the ethtool ioctl interface is leveraged to verify the
> bound
> >>     interface is a veth and, in that case, retrieve the peer ifindex
> and relative
> >>     iface name.
> >>     This means that the discovery feature will only be available on
> Linux platforms.
> >>     Also, there is a fallback to learning from all interfaces (base
> case) if
> >>     discovery fails for any reason.
> >>
> >>     This will simplify dynamic routing deployments by automating the
> interface
> >>     mapping configuration that was previously required for veth-based
> routing
> >>     daemon integrations.
> >>
> >>     Signed-off-by: Matteo Perin <matteo.perin@canonical.com <mailto:
> matteo.perin@canonical.com>>
> >>     ---
> >>     First of all, sorry everyone for the spam of emails I sent to
> forward this
> >>     patch, since this is my first contribution attempt I am still
> finding my
> >>     way around the project patch proposal pipeline.
> >>
> >>     This addition to the OVN controller is an effort to remove the need
> of
> >>     additional configuration when using the routing-protocol-redirect
> with
> >>     one of the most common network setups used along routing daemons.
> >>
> >>     The veth peer discovery is currently made thought an ioctl to
> ethtool and
> >>     this, unfortunately, makes this added feature platfom-dependent.
> >>     As an alternative, I also though about moving the discovery part to
> OVS
> >>     (this will not remove the linux kernel dependency, but it will make
> it
> >>     part of the already in-place OVS linux netdev layer).
> >>     I did not go through with this idea since it seemed to require
> moving too
> >>     many things around and adding space to store peer information to the
> >>     Interface table in OVSDB, and this seemed way too overkill for the
> limited
> >>     scope of this feature.
> >>
> >>     By my initial investigation, the refactoring using this approach
> would require:
> >>     - Extending OVS rtnetlink support to parse IFLA_LINK attribute
> (containing
> >>     veth peer ifindex info).
> >>     - Exposing it via OVSDB Interface:external_ids:peer_ifindex, or
> similar
> >>     - OVN controller then reads peer info from OVS table and calls
> if_indextoname()
> >>     to get interface name.
> >>
> >>     Would this (more "disruptive") technique be more preferable than
> having the
> >>     discovery management in a very specific path in the OVN controller?
> If so,
> >>     I can rework the patch taking into account your suggestions.
> >>
> >>
> >> Hello Matteo,
> >>
> >> thank you for the patch. I'm sorry I didn't reply earlier. To me it
> seems
> >> like it would be better to utilize what ovs already has. If you take a
> look
> >> at the way how "status" column is populated, we already call ethtool
> >> there so it would be that hard to call it twice once with
> "ETHTOOL_GDRVINFO"
> >> which ovs already does, then with "ETHTOOL_GSTATS". Then we could
> >> add the "peer_ifindex" into the "status". This is part of
> "netdev_linux_get_status()".
> >> Having that we could easily check the status column, does that sound
> reasonable
> >> to you?
> >>
> >> One note about "ETHTOOL_GSTATS" the hardcoded value 21 for the array
> >> is not correct. It should be allocated to whatever the
> "ETHTOOL_GDRVINFO"
> >> returns in "n_stats".
> >>
> >> Ccing Ilya if he has any objections or other idea how to extend ovs
> capabilities.
> >
> > We can do that from OVS side.  But we need to be careful to avoid issuing
> > the commands unnecessarily, i.e. we need to cache the results to avoid
> > blocking inside the kernel when we have a lot of ports.  But it's not
> fully
> > clear to me, how we would detect that the value changed in the kernel.
> > This can potentially happen when the peer is moved in and out a different
> > namespace.  It's hard to keep this information up to date.  Any ideas?
>
> Ah, nevermind.  ifindex doesn't change, so we can just check it once.  But
> we need to make sure that we're not reporting a stale value if the
> interface
> is removed and then a new one is added with the same name.
>
> Best regards, Ilya Maximets.
>
diff mbox series

Patch

diff --git a/controller/ovn-controller.8.xml b/controller/ovn-controller.8.xml
index dfc7cc217..92ff5b980 100644
--- a/controller/ovn-controller.8.xml
+++ b/controller/ovn-controller.8.xml
@@ -414,6 +414,14 @@ 
           dynamic-routing-port-name option on Logical_Router_Ports.
           See the <code>ovn-nb</code>(5) for more details.
         </p>
+
+        <p>
+          Note: When using the <code>routing-protocol-redirect</code> option
+          with veth pairs on Linux systems, this mapping may not be necessary
+          as <code>ovn-controller</code> will automatically discover the veth
+          peer interface name. See the <code>routing-protocol-redirect</code>
+          option documentation in <code>ovn-nb</code>(5) for details.
+        </p>
       </dd>
 
       <dt><code>external_ids:ovn-cleanup-on-exit</code></dt>
diff --git a/controller/route.c b/controller/route.c
index ecddd0497..fe141acb7 100644
--- a/controller/route.c
+++ b/controller/route.c
@@ -19,6 +19,11 @@ 
 
 #include <net/if.h>
 
+#ifdef __linux__
+#include <linux/ethtool.h>
+#include <linux/sockios.h>
+#endif
+
 #include "vswitch-idl.h"
 #include "openvswitch/hmap.h"
 #include "openvswitch/vlog.h"
@@ -30,6 +35,7 @@ 
 #include "ha-chassis.h"
 #include "local_data.h"
 #include "route.h"
+#include "socket-util.h"
 
 #include "route-table.h"
 
@@ -38,6 +44,81 @@  VLOG_DEFINE_THIS_MODULE(exchange);
 #define PRIORITY_DEFAULT 1000
 #define PRIORITY_LOCAL_BOUND 100
 
+#ifdef __linux__
+/* Discover the veth peer interface name for a given interface.
+ * Uses ethtool ioctl to verify the device is a veth and get peer_ifindex,
+ * then if_indextoname to get the peer interface name.
+ * Returns the peer interface name, or NULL if not found or on error.
+ * Caller must free the returned string. */
+static char *
+find_veth_peer(const char *ifname)
+{
+    struct ifreq ifr;
+    struct ethtool_drvinfo drvinfo;
+    int peer_ifindex;
+    char *peer_name = NULL;
+    int error;
+
+    if (!ifname) {
+        return NULL;
+    }
+
+    /* Verify device is a veth */
+    memset(&ifr, 0, sizeof ifr);
+    memset(&drvinfo, 0, sizeof drvinfo);
+    ovs_strzcpy(ifr.ifr_name, ifname, sizeof ifr.ifr_name);
+    drvinfo.cmd = ETHTOOL_GDRVINFO;
+    ifr.ifr_data = (void *)&drvinfo;
+
+    error = af_inet_ioctl(SIOCETHTOOL, &ifr);
+    if (error) {
+        return NULL;
+    }
+    if (strcmp(drvinfo.driver, "veth") != 0) {
+        return NULL;
+    }
+
+    /* Get peer_ifindex from ethtool stats. */
+    struct {
+        uint32_t cmd;
+        uint32_t n_stats;
+        uint64_t data[21];
+    } req;
+
+    memset(&req, 0, sizeof req);
+    req.cmd = ETHTOOL_GSTATS;
+    req.n_stats = 1;
+    ifr.ifr_data = (void *)&req;
+
+    error = af_inet_ioctl(SIOCETHTOOL, &ifr);
+    if (error) {
+        return NULL;
+    }
+
+    /* The kernel writes the peer_ifindex into req.data[0] */
+    peer_ifindex = (int) req.data[0];
+    if (peer_ifindex <= 0) {
+        return NULL;
+    }
+
+    /* Convert peer ifindex to interface name */
+    char peer_ifname[IF_NAMESIZE];
+    if (!if_indextoname(peer_ifindex, peer_ifname)) {
+        return NULL;
+    }
+
+    peer_name = xstrdup(peer_ifname);
+    return peer_name;
+}
+#else
+/* Non-Linux platforms do not support veth peer discovery */
+static char *
+find_veth_peer(const char *ifname OVS_UNUSED)
+{
+    return NULL;
+}
+#endif
+
 static bool
 route_exchange_relevant_port(const struct sbrec_port_binding *pb)
 {
@@ -276,9 +357,47 @@  route_run(struct route_ctx_in *r_ctx_in,
             }
 
             if (!port_name) {
-                /* No port-name set, so we learn routes from all ports. */
-                smap_add_nocopy(&ad->bound_ports,
-                                xstrdup(local_peer->logical_port), NULL);
+                /* No explicit port-name set. Check if routing-protocol-
+                 * redirect is configured and try to auto-discover the veth
+                 * peer interface. */
+                const char *redirect_port = smap_get(&repb->options,
+                                                "routing-protocol-redirect");
+                if (redirect_port) {
+                    const char *ifname = ifname_from_port_name(
+                        &port_mapping, r_ctx_in->local_bindings,
+                        r_ctx_in->chassis, redirect_port);
+                    if (ifname) {
+                        char *peer_iface = find_veth_peer(ifname);
+                        if (peer_iface) {
+                            static struct vlog_rate_limit rl =
+                                VLOG_RATE_LIMIT_INIT(5, 20);
+                            VLOG_INFO_RL(&rl, "Auto-discovered veth peer '%s' "
+                                         "for port '%s' (bound to '%s')",
+                                         peer_iface, redirect_port, ifname);
+                            smap_add(&ad->bound_ports,
+                                     local_peer->logical_port, peer_iface);
+                            free(peer_iface);
+                        } else {
+                            /* No veth peer found, fall back to learning from
+                             * all ports on this LRP. */
+                            static struct vlog_rate_limit rl =
+                                VLOG_RATE_LIMIT_INIT(5, 20);
+                            VLOG_INFO_RL(&rl, "Cannot auto-discover veth "
+                                         "peer for port '%s' (bound to "
+                                         "'%s'), falling back to learning "
+                                         "routes from all ports",
+                                         redirect_port, ifname);
+                            smap_add_nocopy(&ad->bound_ports,
+                                            xstrdup(local_peer->logical_port),
+                                            NULL);
+                        }
+                        sset_add(r_ctx_out->filtered_ports, redirect_port);
+                    }
+                } else {
+                    /* No port-name set, so we learn routes from all ports. */
+                    smap_add_nocopy(&ad->bound_ports,
+                                    xstrdup(local_peer->logical_port), NULL);
+                }
             } else {
                 /* If a port_name is set the we filter for the name as set in
                  * the port-mapping or the interface name of the local
diff --git a/northd/northd.c b/northd/northd.c
index c3c0780a3..962f0bd68 100644
--- a/northd/northd.c
+++ b/northd/northd.c
@@ -3902,6 +3902,11 @@  sync_pb_for_lrp(struct ovn_port *op,
             if (portname) {
                 smap_add(&new, "dynamic-routing-port-name", portname);
             }
+            const char *redirect_port = smap_get(&op->nbrp->options,
+                                                 "routing-protocol-redirect");
+            if (redirect_port) {
+                smap_add(&new, "routing-protocol-redirect", redirect_port);
+            }
         }
 
         const char *redistribute_local_only_name =
diff --git a/ovn-nb.xml b/ovn-nb.xml
index a1edd8d35..78834a850 100644
--- a/ovn-nb.xml
+++ b/ovn-nb.xml
@@ -4265,6 +4265,27 @@  or
           Logical Switch and act as if they were listening on Logical Router
           Port's IP addresses.
         </p>
+
+        <p>
+          When used with dynamic routing (when <ref column="options"
+          key="dynamic-routing" table="Logical_Router"/> is set to
+          <code>true</code>), if the specified Logical Switch Port is bound
+          locally and connected to a veth pair, <code>ovn-controller</code>
+          will try to automatically discover the peer interface name and use
+          it for route learning. This removes the need to manually configure
+          <ref column="options" key="dynamic-routing-port-name"/> and/or
+          <ref key="dynamic-routing-port-mapping" table="Open_vSwitch"
+          column="external_ids" db="Open_vSwitch"/> for veth-based routing
+          daemon integrations.
+        </p>
+
+        <p>
+          The auto-discovery feature uses the ethtool interface to identify
+          the veth peer and is only available on Linux systems.
+          If the bound interface is not a veth device or if auto-discovery
+          fails for any reason, the system will fallback to learning routes
+          from all interfaces on the Logical Router Port.
+        </p>
       </column>
 
       <column name="options" key="routing-protocols" type='{"type": "string"}'>
diff --git a/tests/system-ovn.at b/tests/system-ovn.at
index ec3b3735f..add4938c3 100644
--- a/tests/system-ovn.at
+++ b/tests/system-ovn.at
@@ -19590,6 +19590,138 @@  OVS_TRAFFIC_VSWITCHD_STOP(["/failed to query port patch-.*/d
 /Failed to acquire.*/d
 /connection dropped.*/d
 /Couldn't parse IPv6 prefix nexthop.*/d"])
+
+AT_CLEANUP
+])
+
+OVN_FOR_EACH_NORTHD([
+AT_SETUP([dynamic-routing - routing-protocol-redirect auto-discovery])
+AT_SKIP_IF([test "$(uname -s)" != "Linux"])
+
+vni=1337
+VRF_RESERVE([$vni])
+
+# This test validates that automatic veth peer discovery works with
+# routing-protocol-redirect option and that routes can be learned.
+# Note: This feature is Linux-only as it relies on ethtool to query
+# veth peers.
+#
+# Topology:
+#  +----------+
+#  |    lr    | (learns routes from VRF 1337)
+#  +----+-----+
+#       |
+#  +----+----+
+#  |   ls    |
+#  +----+----+
+#       |
+#  +----+------+     +----------+
+#  | bgp-lsp   |-----| bgp-peer | (veth pair - auto-discovered, in VRF 1337)
+#  +-----------+     +----------+
+
+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
+
+# Create VRF for route learning
+OVS_WAIT_WHILE([ip link | grep -q ovnvrf$vni:.*UP])
+check ip link add vrf-$vni type vrf table $vni
+on_exit "ip link del vrf-$vni"
+check ip link set vrf-$vni up
+
+# Create logical router with routing-protocol-redirect
+check ovn-nbctl \
+    -- lr-add lr \
+      -- set Logical_Router lr \
+          options:chassis=hv1 \
+          options:dynamic-routing=true \
+          options:dynamic-routing-vrf-id=$vni \
+          options:dynamic-routing-maintain-vrf=false \
+    -- lrp-add lr lr-ext 00:00:00:01:00:10 1.1.1.1/24 \
+      -- lrp-set-options lr-ext dynamic-routing=true \
+                                routing-protocol-redirect=bgp-lsp \
+    -- ls-add ls \
+      -- lsp-add-router-port ls ls-lr-ext lr-ext \
+      -- lsp-add ls bgp-lsp \
+        -- lsp-set-options bgp-lsp dynamic-routing=true \
+        -- lsp-set-addresses bgp-lsp unknown
+
+# Create veth pair: one end bound to OVN (bgp-ovn), other end for BGP daemon (bgp-peer)
+# The auto-discovery will find bgp-peer from bgp-ovn
+# Delete if already exists to avoid "File exists" errors
+ip link del bgp-ovn 2>/dev/null || true
+check ip link add bgp-ovn type veth peer name bgp-peer
+on_exit "ip link del bgp-ovn 2>/dev/null || true"
+check ip link set bgp-ovn up
+check ip link set bgp-peer master vrf-$vni
+check ip link set bgp-peer up
+check ip addr add 1.1.1.100/24 dev bgp-peer
+
+# Bind bgp-ovn to OVN
+check ovs-vsctl add-port br-int bgp-ovn \
+    -- set interface bgp-ovn external_ids:iface-id=bgp-lsp
+
+wait_for_ports_up bgp-lsp
+
+# Give ovn-controller time to process and discover the veth peer
+sleep 3
+
+# Verify veth peer auto-discovery happened
+AT_CHECK([grep -q "Auto-discovered veth peer 'bgp-peer' for port 'bgp-lsp'" ovn-controller.log], [0])
+
+# Add a route to the VRF (simulating BGP learning a route via bgp-peer)
+AT_CHECK([ip route add 10.10.1.1 via 1.1.1.2 vrf vrf-$vni proto zebra])
+
+# Verify learned route appears in SB database
+OVS_WAIT_UNTIL([ovn-sbctl list Learned_Route | grep ip_prefix | grep -Fe 10.10.1.1])
+
+# Add a second route
+AT_CHECK([ip route add 10.10.2.1 via 1.1.1.2 vrf vrf-$vni proto zebra])
+
+# Verify both routes appear in SB database
+OVS_WAIT_FOR_OUTPUT([ovn-sbctl list Learned_Route | grep ip_prefix | sort], [0], [dnl
+ip_prefix           : "10.10.1.1"
+ip_prefix           : "10.10.2.1"
+])
+
+# Remove one route
+AT_CHECK([ip route del 10.10.2.1 via 1.1.1.2 vrf vrf-$vni])
+
+# Verify only one route remains
+OVS_WAIT_FOR_OUTPUT([ovn-sbctl list Learned_Route | grep ip_prefix | sort], [0], [dnl
+ip_prefix           : "10.10.1.1"
+])
+
+# Remove second route
+AT_CHECK([ip route del 10.10.1.1 via 1.1.1.2 vrf vrf-$vni])
+
+# Verify all routes removed
+OVS_WAIT_FOR_OUTPUT([ovn-sbctl list Learned_Route | grep ip_prefix | sort], [0], [dnl
+])
+
+# Delete logical objects before cleanup
+check ovn-nbctl --wait=hv ls-del ls
+check ovn-nbctl --wait=hv lr-del lr
+
+OVN_CLEANUP_CONTROLLER([hv1])
+
+OVN_CLEANUP_NORTHD
+
+as
+OVS_TRAFFIC_VSWITCHD_STOP(["/.*error receiving.*/d
+/failed to query port patch-.*/d
+/.*terminating with signal 15.*/d
+/could not open network device bgp-ovn.*/d"])
+
 AT_CLEANUP
 ])