Commit 5168a85481 for frr
commit 5168a854819e4c34aed0d17a4efcc6f9306b578d
Author: Yuya Kusakabe <yuya.kusakabe@gmail.com>
Date: Tue Sep 29 10:12:56 2026 +0900
tests: check that stale VPN path removal reaches imported VRFs
ExaBGP rather than FRR plays the restarting peer, because FRR does not
advertise the graceful restart capability for VPN address families.
Assisted-by: LLM
Signed-off-by: Yuya Kusakabe <yuya.kusakabe@gmail.com>
diff --git a/tests/topotests/bgp_vpnv4_gr_stale/__init__.py b/tests/topotests/bgp_vpnv4_gr_stale/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/tests/topotests/bgp_vpnv4_gr_stale/peer1-restart/exabgp.cfg b/tests/topotests/bgp_vpnv4_gr_stale/peer1-restart/exabgp.cfg
new file mode 100644
index 0000000000..000392e2cf
--- /dev/null
+++ b/tests/topotests/bgp_vpnv4_gr_stale/peer1-restart/exabgp.cfg
@@ -0,0 +1,23 @@
+neighbor 192.168.255.1 {
+ router-id 192.0.2.2;
+ local-address 192.168.255.2;
+ local-as 65002;
+ peer-as 65001;
+
+ capability {
+ graceful-restart 120;
+ }
+
+ family {
+ ipv4 mpls-vpn;
+ }
+
+ static {
+ route 10.0.2.1/32 {
+ rd 65002:10;
+ next-hop 192.168.255.2;
+ extended-community [ target:65002:10 ];
+ label 200;
+ }
+ }
+}
diff --git a/tests/topotests/bgp_vpnv4_gr_stale/peer1-restart/exabgp.env b/tests/topotests/bgp_vpnv4_gr_stale/peer1-restart/exabgp.env
new file mode 100644
index 0000000000..6c554f5fa8
--- /dev/null
+++ b/tests/topotests/bgp_vpnv4_gr_stale/peer1-restart/exabgp.env
@@ -0,0 +1,53 @@
+
+[exabgp.api]
+encoder = text
+highres = false
+respawn = false
+socket = ''
+
+[exabgp.bgp]
+openwait = 60
+
+[exabgp.cache]
+attributes = true
+nexthops = true
+
+[exabgp.daemon]
+daemonize = true
+pid = '/var/run/exabgp/exabgp.pid'
+user = 'exabgp'
+
+[exabgp.log]
+all = false
+configuration = true
+daemon = true
+destination = '/var/log/exabgp.log'
+enable = true
+level = INFO
+message = false
+network = true
+packets = false
+parser = false
+processes = true
+reactor = true
+rib = false
+routes = false
+short = false
+timers = false
+
+[exabgp.pdb]
+enable = false
+
+[exabgp.profile]
+enable = false
+file = ''
+
+[exabgp.reactor]
+speed = 1.0
+
+[exabgp.tcp]
+acl = false
+bind = ''
+delay = 0
+once = false
+port = 179
diff --git a/tests/topotests/bgp_vpnv4_gr_stale/peer1/exabgp.cfg b/tests/topotests/bgp_vpnv4_gr_stale/peer1/exabgp.cfg
new file mode 100644
index 0000000000..87042ba72a
--- /dev/null
+++ b/tests/topotests/bgp_vpnv4_gr_stale/peer1/exabgp.cfg
@@ -0,0 +1,29 @@
+neighbor 192.168.255.1 {
+ router-id 192.0.2.2;
+ local-address 192.168.255.2;
+ local-as 65002;
+ peer-as 65001;
+
+ capability {
+ graceful-restart 120;
+ }
+
+ family {
+ ipv4 mpls-vpn;
+ }
+
+ static {
+ route 10.0.1.1/32 {
+ rd 65002:10;
+ next-hop 192.168.255.2;
+ extended-community [ target:65002:10 ];
+ label 200;
+ }
+ route 10.0.2.1/32 {
+ rd 65002:10;
+ next-hop 192.168.255.2;
+ extended-community [ target:65002:10 ];
+ label 200;
+ }
+ }
+}
diff --git a/tests/topotests/bgp_vpnv4_gr_stale/peer1/exabgp.env b/tests/topotests/bgp_vpnv4_gr_stale/peer1/exabgp.env
new file mode 100644
index 0000000000..6c554f5fa8
--- /dev/null
+++ b/tests/topotests/bgp_vpnv4_gr_stale/peer1/exabgp.env
@@ -0,0 +1,53 @@
+
+[exabgp.api]
+encoder = text
+highres = false
+respawn = false
+socket = ''
+
+[exabgp.bgp]
+openwait = 60
+
+[exabgp.cache]
+attributes = true
+nexthops = true
+
+[exabgp.daemon]
+daemonize = true
+pid = '/var/run/exabgp/exabgp.pid'
+user = 'exabgp'
+
+[exabgp.log]
+all = false
+configuration = true
+daemon = true
+destination = '/var/log/exabgp.log'
+enable = true
+level = INFO
+message = false
+network = true
+packets = false
+parser = false
+processes = true
+reactor = true
+rib = false
+routes = false
+short = false
+timers = false
+
+[exabgp.pdb]
+enable = false
+
+[exabgp.profile]
+enable = false
+file = ''
+
+[exabgp.reactor]
+speed = 1.0
+
+[exabgp.tcp]
+acl = false
+bind = ''
+delay = 0
+once = false
+port = 179
diff --git a/tests/topotests/bgp_vpnv4_gr_stale/r1/frr.conf b/tests/topotests/bgp_vpnv4_gr_stale/r1/frr.conf
new file mode 100644
index 0000000000..2c069778df
--- /dev/null
+++ b/tests/topotests/bgp_vpnv4_gr_stale/r1/frr.conf
@@ -0,0 +1,28 @@
+interface r1-eth0
+ ip address 192.168.255.1/24
+ mpls bgp forwarding
+!
+router bgp 65001
+ bgp router-id 192.0.2.1
+ no bgp ebgp-requires-policy
+ timers bgp 1 3
+ neighbor 192.168.255.2 remote-as external
+ neighbor 192.168.255.2 timers connect 1
+ address-family ipv4 unicast
+ no neighbor 192.168.255.2 activate
+ exit-address-family
+ address-family ipv4 vpn
+ neighbor 192.168.255.2 activate
+ exit-address-family
+!
+router bgp 65001 vrf vrf1
+ bgp router-id 192.0.2.1
+ address-family ipv4 unicast
+ label vpn export 100
+ rd vpn export 65001:10
+ rt vpn import 65002:10
+ rt vpn export 65001:10
+ export vpn
+ import vpn
+ exit-address-family
+!
diff --git a/tests/topotests/bgp_vpnv4_gr_stale/test_bgp_vpnv4_gr_stale.py b/tests/topotests/bgp_vpnv4_gr_stale/test_bgp_vpnv4_gr_stale.py
new file mode 100644
index 0000000000..08c24ee7b1
--- /dev/null
+++ b/tests/topotests/bgp_vpnv4_gr_stale/test_bgp_vpnv4_gr_stale.py
@@ -0,0 +1,137 @@
+#!/usr/bin/env python
+# SPDX-License-Identifier: ISC
+#
+# Copyright (c) 2026 by
+# Yuya Kusakabe <yuya.kusakabe@gmail.com>
+#
+
+"""
+Check that a VPN path removed by graceful restart stale cleanup is also
+withdrawn from the VRF that imported it.
+
+peer1 advertises 10.0.1.1/32 and 10.0.2.1/32 as VPNv4 routes, which r1
+imports into vrf1. peer1 dies without sending a NOTIFICATION, so r1
+keeps its paths as stale. peer1 comes back advertising only
+10.0.2.1/32, and its End-of-RIB makes r1 remove the stale 10.0.1.1/32.
+"""
+
+import os
+import sys
+import json
+import pytest
+
+CWD = os.path.dirname(os.path.realpath(__file__))
+sys.path.append(os.path.join(CWD, "../"))
+
+# pylint: disable=C0413
+from lib import topotest
+from lib.topogen import Topogen, get_topogen
+from lib.common_config import step
+
+pytestmark = [pytest.mark.bgpd]
+
+GONE = "10.0.1.1/32"
+KEPT = "10.0.2.1/32"
+RD = "65002:10"
+
+
+def build_topo(tgen):
+ tgen.add_router("r1")
+ peer1 = tgen.add_exabgp_peer(
+ "peer1", ip="192.168.255.2/24", defaultRoute="via 192.168.255.1"
+ )
+
+ switch = tgen.add_switch("s1")
+ switch.add_link(tgen.gears["r1"])
+ switch.add_link(peer1)
+
+
+def start_peer1(tgen, cfg_dir):
+ peer_dir = os.path.join(CWD, cfg_dir)
+ tgen.gears["peer1"].start(peer_dir, os.path.join(peer_dir, "exabgp.env"))
+
+
+def setup_module(mod):
+ tgen = Topogen(build_topo, mod.__name__)
+ if not tgen.hasmpls:
+ pytest.skip("MPLS is not available")
+ tgen.start_topology()
+
+ r1 = tgen.gears["r1"]
+ r1.cmd_raises("ip link add vrf1 type vrf table 10")
+ r1.cmd_raises("ip link set dev vrf1 up")
+ r1.load_frr_config(os.path.join(CWD, "r1/frr.conf"))
+ tgen.start_router()
+
+ start_peer1(tgen, "peer1")
+
+
+def teardown_module(mod):
+ get_topogen().stop_topology()
+
+
+def vpn_paths(router, prefix):
+ output = json.loads(router.vtysh_cmd("show bgp ipv4 vpn json"))
+ return (
+ output.get("routes", {})
+ .get("routeDistinguishers", {})
+ .get(RD, {})
+ .get(prefix, [])
+ )
+
+
+def vrf_paths(router, prefix):
+ output = json.loads(
+ router.vtysh_cmd("show bgp vrf vrf1 ipv4 unicast {} json".format(prefix))
+ )
+ return output.get("paths", [])
+
+
+def kernel_has(router, prefix):
+ output = json.loads(router.cmd("ip -j route show table 10 {}".format(prefix)))
+ return bool(output)
+
+
+def wait_for(func, expected):
+ _, result = topotest.run_and_expect(func, expected, count=15, wait=1)
+ return result
+
+
+def test_bgp_vpnv4_gr_stale():
+ tgen = get_topogen()
+ if tgen.routers_have_failure():
+ pytest.skip(tgen.errors)
+
+ r1 = tgen.gears["r1"]
+ peer1 = tgen.gears["peer1"]
+
+ step("Check that r1 imports both VPN routes into vrf1")
+ for prefix in (GONE, KEPT):
+ assert wait_for(lambda: len(vrf_paths(r1, prefix)), 1) == 1
+ assert wait_for(lambda: kernel_has(r1, prefix), True), "not in kernel"
+
+ step("Kill peer1 and check that r1 keeps its VPN paths as stale")
+ peer1.run("kill -9 `cat /var/run/exabgp/exabgp.pid`")
+ peer1.run("rm -f /var/run/exabgp/exabgp.pid")
+
+ def stale():
+ paths = vpn_paths(r1, GONE)
+ return bool(paths) and all(p.get("stale") for p in paths)
+
+ assert wait_for(stale, True), "VPN path not kept as stale"
+
+ step("Restart peer1 without {}".format(GONE))
+ start_peer1(tgen, "peer1-restart")
+ assert wait_for(lambda: len(vpn_paths(r1, KEPT)), 1) == 1
+ assert wait_for(lambda: len(vpn_paths(r1, GONE)), 0) == 0, "stale VPN path kept"
+
+ step("Check that {} is withdrawn from vrf1".format(GONE))
+ assert wait_for(lambda: len(vrf_paths(r1, GONE)), 0) == 0, "import left in vrf1"
+ assert wait_for(lambda: kernel_has(r1, GONE), False) is False, "left in kernel"
+ assert len(vrf_paths(r1, KEPT)) == 1
+ assert kernel_has(r1, KEPT)
+
+
+if __name__ == "__main__":
+ args = ["-s"] + sys.argv[1:]
+ sys.exit(pytest.main(args))