Commit 10976e3e03 for frr
commit 10976e3e035d8bae3cdf99939aa44008b4994c83
Author: Kyle Qi <kyle@nexthop.ai>
Date: Tue Oct 20 18:23:42 2026 +0000
tests: add bgp_bmp_locrib_eor topotest
Check that bgpd sends exactly one BMP Loc-RIB End-of-RIB per AFI/SAFI,
regardless of how many peers negotiated it.
Signed-off-by: Kyle Qi <kyle@nexthop.ai>
diff --git a/tests/topotests/bgp_bmp_locrib_eor/__init__.py b/tests/topotests/bgp_bmp_locrib_eor/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/tests/topotests/bgp_bmp_locrib_eor/r1/frr.conf b/tests/topotests/bgp_bmp_locrib_eor/r1/frr.conf
new file mode 100644
index 0000000000..bcdc2852d8
--- /dev/null
+++ b/tests/topotests/bgp_bmp_locrib_eor/r1/frr.conf
@@ -0,0 +1,21 @@
+!
+interface r1-eth0
+ ip address 192.0.2.1/24
+!
+interface r1-eth1
+ ip address 192.168.0.1/24
+ ipv6 address 192:168::1/64
+!
+router bgp 65501
+ bgp router-id 192.168.0.1
+ timers bgp 1 10
+ no bgp ebgp-requires-policy
+ neighbor 192.168.0.2 remote-as 65502
+ neighbor 192:168::2 remote-as 65502
+ !
+ address-family ipv4 unicast
+ neighbor 192.168.0.2 activate
+ neighbor 192:168::2 activate
+ exit-address-family
+exit
+!
diff --git a/tests/topotests/bgp_bmp_locrib_eor/r2/frr.conf b/tests/topotests/bgp_bmp_locrib_eor/r2/frr.conf
new file mode 100644
index 0000000000..a35cf9ac6f
--- /dev/null
+++ b/tests/topotests/bgp_bmp_locrib_eor/r2/frr.conf
@@ -0,0 +1,18 @@
+!
+interface r2-eth0
+ ip address 192.168.0.2/24
+ ipv6 address 192:168::2/64
+!
+router bgp 65502
+ bgp router-id 192.168.0.2
+ timers bgp 1 10
+ no bgp ebgp-requires-policy
+ neighbor 192.168.0.1 remote-as 65501
+ neighbor 192:168::1 remote-as 65501
+ !
+ address-family ipv4 unicast
+ neighbor 192.168.0.1 activate
+ neighbor 192:168::1 activate
+ exit-address-family
+exit
+!
diff --git a/tests/topotests/bgp_bmp_locrib_eor/test_bgp_bmp_locrib_eor.py b/tests/topotests/bgp_bmp_locrib_eor/test_bgp_bmp_locrib_eor.py
new file mode 100644
index 0000000000..b763187dbe
--- /dev/null
+++ b/tests/topotests/bgp_bmp_locrib_eor/test_bgp_bmp_locrib_eor.py
@@ -0,0 +1,159 @@
+#!/usr/bin/env python
+# SPDX-License-Identifier: ISC
+#
+# Copyright (c) 2026 by
+# Kyle Qi <kyle@nexthop.ai>
+#
+# BMP Loc-RIB End-of-RIB is sent once per AFI/SAFI.
+#
+# +------+ +------+ +------+
+# | BMP1 |----s1----| R1 |----------| R2 |
+# +------+ +------+ +------+
+#
+# R1 has two eBGP sessions to R2 (IPv4 and IPv6 transport), both negotiating
+# IPv4 unicast and neither negotiating IPv6 unicast. R1 monitors IPv4 and IPv6
+# unicast pre-policy, post-policy and loc-rib. The initial table dump must carry
+# exactly one Loc-RIB End-of-RIB per monitored AFI/SAFI, regardless of how many
+# peers negotiated it (two for IPv4, zero for IPv6). Pre- and post-policy
+# End-of-RIBs remain per negotiated peer.
+
+import functools
+import json
+import os
+import sys
+
+import pytest
+
+CWD = os.path.dirname(os.path.realpath(__file__))
+sys.path.append(os.path.join(CWD, "../"))
+sys.path.append(os.path.join(CWD, "../lib/"))
+
+# pylint: disable=C0413
+from lib import topotest
+from lib.topogen import Topogen, TopoRouter, get_topogen
+from lib.topolog import logger
+
+pytestmark = [pytest.mark.bgpd]
+
+# configured only once BGP is up, so the initial dump sees negotiated peers
+BMP_TARGET_CFG = """
+configure terminal
+router bgp 65501
+ bmp targets bmp1
+ bmp monitor ipv4 unicast pre-policy
+ bmp monitor ipv6 unicast pre-policy
+ bmp monitor ipv4 unicast post-policy
+ bmp monitor ipv6 unicast post-policy
+ bmp monitor ipv4 unicast loc-rib
+ bmp monitor ipv6 unicast loc-rib
+ bmp connect 192.0.2.10 port 1789 min-retry 100 max-retry 10000
+ exit
+exit
+"""
+
+
+def build_topo(tgen):
+ tgen.add_router("r1")
+ tgen.add_router("r2")
+ tgen.add_bmp_server("bmp1", ip="192.0.2.10", defaultRoute="via 192.0.2.1")
+
+ switch = tgen.add_switch("s1")
+ switch.add_link(tgen.gears["r1"])
+ switch.add_link(tgen.gears["bmp1"])
+
+ tgen.add_link(tgen.gears["r1"], tgen.gears["r2"], "r1-eth1", "r2-eth0")
+
+
+def setup_module(mod):
+ tgen = Topogen(build_topo, mod.__name__)
+ tgen.start_topology()
+
+ for rname, router in tgen.routers().items():
+ logger.info("Loading router %s" % rname)
+ router.load_frr_config(
+ os.path.join(CWD, "{}/frr.conf".format(rname)),
+ [(TopoRouter.RD_ZEBRA, None), (TopoRouter.RD_BGP, "-M bmp")],
+ )
+
+ tgen.start_router()
+
+ logger.info("starting BMP servers")
+ for bmp_name, server in tgen.get_bmp_servers().items():
+ server.start(log_file=os.path.join(tgen.logdir, bmp_name, "bmp.log"))
+
+
+def teardown_module(_mod):
+ get_topogen().stop_topology()
+
+
+def test_bgp_convergence():
+ tgen = get_topogen()
+ if tgen.routers_have_failure():
+ pytest.skip(tgen.errors)
+
+ expected = {
+ "peers": {
+ "192.168.0.2": {"state": "Established"},
+ "192:168::2": {"state": "Established"},
+ }
+ }
+ test_func = functools.partial(
+ topotest.router_json_cmp,
+ tgen.gears["r1"],
+ "show bgp ipv4 unicast summary json",
+ expected,
+ )
+ _, result = topotest.run_and_expect(test_func, None, count=60, wait=1)
+ assert result is None, "r1: both IPv4 unicast sessions did not establish"
+
+
+def _eor_counts(bmp1, log_file):
+ """Count End-of-RIBs in the BMP log per (policy, afi, safi)."""
+ counts = {}
+ for line in bmp1.run("cat {}".format(log_file)).splitlines():
+ try:
+ msg = json.loads(line)
+ except ValueError:
+ continue
+ # an EoR is a route monitoring message without any prefix
+ if msg.get("bmp_log_type") not in ("update", "withdraw"):
+ continue
+ if "ip_prefix" in msg:
+ continue
+ # IPv4 unicast EoR has no MP_UNREACH_NLRI, hence no afi/safi
+ key = "{} {}/{}".format(msg["policy"], msg.get("afi", 1), msg.get("safi", 1))
+ counts[key] = counts.get(key, 0) + 1
+ return counts
+
+
+def test_bmp_locrib_eor_once_per_afi_safi():
+ tgen = get_topogen()
+ if tgen.routers_have_failure():
+ pytest.skip(tgen.errors)
+
+ tgen.gears["r1"].vtysh_cmd(BMP_TARGET_CFG)
+
+ bmp1 = tgen.gears["bmp1"]
+ log_file = os.path.join(tgen.logdir, "bmp1", "bmp.log")
+
+ # IPv6 is dumped after IPv4, so its Loc-RIB EoR closes the initial dump
+ expected = {
+ "pre-policy 1/1": 2,
+ "post-policy 1/1": 2,
+ "loc-rib 1/1": 1,
+ "loc-rib 2/1": 1,
+ }
+
+ def _check():
+ counts = _eor_counts(bmp1, log_file)
+ logger.info("BMP End-of-RIB counts: {}".format(counts))
+ return counts
+
+ _, counts = topotest.run_and_expect(_check, expected, count=30, wait=1)
+ assert (
+ counts == expected
+ ), "unexpected BMP End-of-RIB counts: got {}, expected {}".format(counts, expected)
+
+
+if __name__ == "__main__":
+ sys.exit(pytest.main(["-s"] + sys.argv[1:]))