Commit 314ad4d36f for frr
commit 314ad4d36f83c5b05c8a75eb41b6125174e3e347
Author: Yuqing Zhao <galadriel.zyq@alibaba-inc.com>
Date: Mon Sep 14 19:04:26 2026 +0800
tests: verify FPM resolved-via with recursive static routes
Add a topotest case (test_fpm_resolved_via_recursive_routes) that
configures a three-route resolution chain through the connected
route: the first route resolves via the connected route, and the
other two resolve through the previous route.
The test collects the resolved-via information vtysh reports for the
nexthop groups used by the routes, and asserts that every value
received by the FPM listener matches what vtysh reports.
Signed-off-by: Yuqing Zhao <galadriel.zyq@alibaba-inc.com>
diff --git a/tests/topotests/fpm_testing_topo1/test_fpm_topo1.py b/tests/topotests/fpm_testing_topo1/test_fpm_topo1.py
index b3365dfadc..a051cc3df6 100644
--- a/tests/topotests/fpm_testing_topo1/test_fpm_topo1.py
+++ b/tests/topotests/fpm_testing_topo1/test_fpm_topo1.py
@@ -14,6 +14,7 @@ test_fpm_topo1.py: Testing FPM module
"""
import os
+import re
import sys
import pytest
import json
@@ -399,6 +400,218 @@ def test_fpm_system_route_nhg_sent_to_fpm_not_kernel():
router.run("ip route del 172.16.2.0/24 dev r1-eth0")
+def _get_route_nhg_id(router, prefix):
+ """
+ Return the nexthop group id for the route ``prefix``, or None if the
+ route is not present.
+ """
+ output = router.vtysh_cmd("show ip route {} json".format(prefix))
+ try:
+ route_json = json.loads(output)
+ except json.JSONDecodeError:
+ return None
+
+ for pfx, routes in route_json.items():
+ if pfx == prefix and routes:
+ return routes[0].get("nexthopGroupId")
+
+ return None
+
+
+def _get_nhg_tree(router, nhg_id):
+ """
+ Walk the nexthop group dependency tree rooted at ``nhg_id`` and return
+ {nhg_id: nhg json object} as reported by vtysh.
+ """
+ tree = {}
+ todo = [nhg_id]
+
+ while todo:
+ cur = todo.pop()
+ if cur in tree:
+ continue
+
+ output = router.vtysh_cmd("show nexthop-group rib {} json".format(cur))
+ try:
+ data = json.loads(output)
+ except json.JSONDecodeError:
+ continue
+
+ nhg = data.get(str(cur))
+ if nhg is None:
+ continue
+
+ tree[cur] = nhg
+ todo.extend(nhg.get("depends", []))
+
+ return tree
+
+
+def _get_fpm_resolved_via(router):
+ """
+ Dump the fpm_listener tables and return {nhg_id: resolved via id} for
+ the nexthop groups received with a resolved-via attribute.
+ """
+ if not _fpm_listener_dump(router):
+ return {}
+
+ # Entries are not always newline-terminated (a group printed without
+ # its nexthop list runs into the next entry), so parse them by
+ # splitting on the entry marker instead of line by line.
+ dump = _read_fpm_dump(router).split("=== Route Tree Dump ===")[0]
+
+ resolved_via = {}
+ for entry in re.split(r"(?= ID: \d+,)", dump):
+ nhg_id = re.match(r" ID: (\d+),", entry)
+ value = re.search(r"ResolvedVia: (\d+)", entry)
+ if nhg_id and value:
+ resolved_via[int(nhg_id.group(1))] = int(value.group(1))
+
+ return resolved_via
+
+
+def test_fpm_resolved_via_recursive_routes():
+ """
+ Check that the resolved-via information zebra sends down the FPM pipe
+ matches what vtysh reports, for static routes that resolve recursively.
+
+ The routes form a small resolution chain:
+ - 10.200.10.0/24 is resolved via the connected route
+ - 10.200.20.0/24 is resolved via 10.200.10.0/24
+ - 10.200.30.0/24 is resolved via 10.200.20.0/24
+ """
+ tgen = get_topogen()
+ router = tgen.gears["r1"]
+
+ connected_prefix = "192.168.44.0/24"
+ prefixes = ["10.200.10.0/24", "10.200.20.0/24", "10.200.30.0/24"]
+
+ router.vtysh_cmd(
+ """
+ configure terminal
+ ip route 10.200.10.0/24 192.168.44.2
+ ip route 10.200.20.0/24 10.200.10.1
+ ip route 10.200.30.0/24 10.200.20.1
+ """
+ )
+
+ route_nhg_ids = {}
+
+ def routes_resolved():
+ for prefix in prefixes:
+ output = router.vtysh_cmd("show ip route {} json".format(prefix))
+ try:
+ route_json = json.loads(output)
+ except json.JSONDecodeError:
+ return False
+
+ routes = route_json.get(prefix)
+ if not routes:
+ return False
+
+ nexthop_group_id = routes[0].get("nexthopGroupId")
+ if nexthop_group_id is None:
+ return False
+ route_nhg_ids[prefix] = nexthop_group_id
+
+ # The routes need to be resolved before the resolved-via
+ # information is reported for their nexthops.
+ if not any(
+ "resolvedVia" in nexthop for nexthop in routes[0].get("nexthops", [])
+ ):
+ return False
+
+ return True
+
+ success, _ = topotest.run_and_expect(routes_resolved, True, count=30, wait=1)
+ assert success, "Recursive static routes were not resolved: {}".format(
+ route_nhg_ids
+ )
+
+ # Collect the nexthop groups used by the routes and the resolved-via
+ # values vtysh reports for them.
+ our_nhgs = {}
+ vtysh_resolved_via = {}
+ for prefix in prefixes:
+ nhg_id = _get_route_nhg_id(router, prefix)
+ if nhg_id is None:
+ continue
+ for cur_id, nhg in _get_nhg_tree(router, nhg_id).items():
+ our_nhgs[cur_id] = nhg
+ for nexthop in nhg.get("nexthops", []):
+ if "resolvedVia" in nexthop:
+ vtysh_resolved_via.setdefault(cur_id, set()).add(
+ nexthop["resolvedVia"]
+ )
+
+ # Nexthop groups without dependencies are sent to the FPM as singleton
+ # nexthop messages, which carry the resolved-via attribute; groups with
+ # dependencies (the recursive parents included) are sent as NHA_GROUP
+ # messages, which cannot carry it. Require every mapping that can be
+ # observed on the FPM pipe, so an incomplete one fails the test.
+ expected_resolved_via = {
+ nhg_id: values
+ for nhg_id, values in vtysh_resolved_via.items()
+ if not our_nhgs[nhg_id].get("depends")
+ }
+ assert (
+ expected_resolved_via
+ ), "vtysh reports no resolved-via for singleton nexthop groups: " "{}".format(
+ vtysh_resolved_via
+ )
+
+ # The first route's nexthop resolves via the connected route, so that
+ # nexthop group id is what vtysh should report as the resolved-via.
+ connected_nhg_id = _get_route_nhg_id(router, connected_prefix)
+ assert connected_nhg_id is not None, "Connected route has no nexthop group id"
+ expected_values = set().union(*expected_resolved_via.values())
+ assert (
+ connected_nhg_id in expected_values
+ ), "vtysh did not report resolved-via {} for the routes: {}".format(
+ connected_nhg_id, vtysh_resolved_via
+ )
+
+ # Wait until the FPM listener has received all of them over the FPM pipe.
+ def fpm_has_expected_resolved_via():
+ fpm_resolved_via = _get_fpm_resolved_via(router)
+ for nhg_id, values in expected_resolved_via.items():
+ if fpm_resolved_via.get(nhg_id) not in values:
+ return False
+ return True
+
+ success, _ = topotest.run_and_expect(
+ fpm_has_expected_resolved_via, True, count=30, wait=1
+ )
+ assert success, (
+ "FPM pipe did not report the expected resolved-via mappings.\n"
+ "Expected: {}\nFPM: {}\nFPM dump:\n{}".format(
+ expected_resolved_via,
+ _get_fpm_resolved_via(router),
+ _read_fpm_dump(router)[-2000:],
+ )
+ )
+
+ # The resolved-via value sent down the FPM pipe must match the value
+ # vtysh reports for the same nexthop group.
+ fpm_resolved_via = _get_fpm_resolved_via(router)
+ for nhg_id, resolved_via in fpm_resolved_via.items():
+ if nhg_id in vtysh_resolved_via:
+ assert resolved_via in vtysh_resolved_via[nhg_id], (
+ "FPM received resolved-via {} for NHG {}, but vtysh "
+ "reports {}".format(resolved_via, nhg_id, vtysh_resolved_via[nhg_id])
+ )
+
+ # Cleanup
+ router.vtysh_cmd(
+ """
+ configure terminal
+ no ip route 10.200.10.0/24 192.168.44.2
+ no ip route 10.200.20.0/24 10.200.10.1
+ no ip route 10.200.30.0/24 10.200.20.1
+ """
+ )
+
+
if __name__ == "__main__":
args = ["-s"] + sys.argv[1:]
sys.exit(pytest.main(args))