Commit 410b1fd0cd for frr

commit 410b1fd0cd9b689a4e2d73ed60a24f314f883ac0
Author: Donald Sharp <sharpd@nvidia.com>
Date:   Fri Sep 25 20:57:14 2026 -0400

    tests: Add a kernel_routes generic function to be used to get kernel state

    Start the ability to make the topotests return json output for both
    freebsd and linux that makes the kernel routes a json structure.
    Follow the linux side of the world and make the freebsd look as much
    alike as the linux side json as possible.

    Signed-off-by: Donald Sharp <sharpd@nvidia.com>

diff --git a/tests/topotests/lib/kernel_routes.py b/tests/topotests/lib/kernel_routes.py
new file mode 100644
index 0000000000..547b8f0859
--- /dev/null
+++ b/tests/topotests/lib/kernel_routes.py
@@ -0,0 +1,279 @@
+# SPDX-License-Identifier: ISC
+"""Kernel routing table in iproute2 JSON form.
+
+``kernel_routes()`` is the topotest helper for "what is installed in the
+kernel". On Linux it returns the parsed output of ``ip -j route show``. On
+FreeBSD it runs ``netstat --libxo json -rn`` and builds the same shape.
+
+Only values netstat actually reports are filled in. Linux fields with no
+FreeBSD equivalent (``protocol``, ``metric``, ``pref``, ``flags``, ``nhid``,
+``scope``, ``prefsrc``) are omitted rather than invented. A few encodings are
+normalized so the same route is written the way iproute2 writes it:
+
+* ``0.0.0.0/0`` and ``::/0`` become ``default``
+* host routes drop a trailing ``/32`` or ``/128``
+* IPv6 zone indexes (``fe80::1%em0``) are removed; the interface stays in ``dev``
+* a ``link#N`` gateway is an interface route, so no ``gateway`` key is emitted
+* routes that share a destination and ``nhg-kidx`` become one object with a
+  ``nexthops`` array (``weight`` is kept there, as in iproute2)
+* ``RTF_BLACKHOLE`` / ``RTF_REJECT`` become ``type`` ``blackhole`` /
+  ``unreachable``
+
+FreeBSD keeps interface host routes in the same FIB. Linux ``ip route show``
+leaves those in the local table, so a FreeBSD result includes them. Select a
+FIB with ``table`` (a FIB number). A Linux VRF name has no netstat equivalent.
+"""
+
+import json
+import re
+import shlex
+import sys
+
+
+_LINK_GATEWAY = re.compile(r"^link#\d+$")
+_ZONE = re.compile(r"%[^/%]+")
+_IPV4_ADDR = re.compile(r"^\d+\.\d+\.\d+\.\d+$")
+
+_FAMILY_BY_AF = {
+    "Internet": "ipv4",
+    "inet": "ipv4",
+    "Internet6": "ipv6",
+    "inet6": "ipv6",
+}
+
+
+class KernelRoutesError(ValueError):
+    """kernel_routes() could not return a routing table."""
+
+
+def kernel_routes(node, vrf=None, table=None, family="ipv4"):
+    """Return kernel routes as a list of iproute2 JSON objects.
+
+    ``family`` is ``ipv4`` (the default, ``ip -j route show``), ``ipv6``
+    (``ip -j -6 route show``), or ``all``.
+
+    ``vrf`` is a Linux VRF name (``ip route show vrf NAME``). On FreeBSD only
+    ``None`` and ``"default"`` are accepted; pass ``table`` set to the FIB
+    number instead. ``table`` is an iproute2 table (``main``, ``local``, an
+    id, ...) or, on FreeBSD, a FIB number. ``main`` and ``default`` select
+    the FIB ``netstat`` shows without ``-F``.
+
+    ``node`` is a topotest router. The command runs inside that router.
+    """
+    family = _normalize_family(family)
+    if sys.platform.startswith("freebsd"):
+        return _kernel_routes_freebsd(node, vrf, table, family)
+    return _kernel_routes_linux(node, vrf, table, family)
+
+
+def _normalize_family(family):
+    if family in ("ipv4", "inet", "4", 4):
+        return "ipv4"
+    if family in ("ipv6", "inet6", "6", 6):
+        return "ipv6"
+    if family == "all":
+        return "all"
+    raise KernelRoutesError(
+        "family must be 'ipv4', 'ipv6', or 'all', not %r" % (family,)
+    )
+
+
+def _node_stdout(node, argv):
+    """Run argv inside the router. Prefer cmd_raises, which takes a list."""
+    if hasattr(node, "cmd_raises"):
+        return node.cmd_raises(argv)
+    command = " ".join(shlex.quote(str(arg)) for arg in argv)
+    if hasattr(node, "run"):
+        return node.run(command)
+    return node.cmd(command)
+
+
+def _load_json(text, what):
+    if isinstance(text, bytes):
+        text = text.decode()
+    text = (text or "").strip()
+    if not text:
+        return []
+    try:
+        return json.loads(text)
+    except json.JSONDecodeError as exc:
+        raise KernelRoutesError("invalid JSON from %s" % (what,)) from exc
+
+
+def _linux_argv(vrf, table, family):
+    argv = ["ip", "-j", "-4" if family == "ipv4" else "-6", "route", "show"]
+    if vrf is not None:
+        argv.extend(("vrf", str(vrf)))
+    if table is not None:
+        argv.extend(("table", str(table)))
+    return argv
+
+
+def _kernel_routes_linux(node, vrf, table, family):
+    families = ("ipv4", "ipv6") if family == "all" else (family,)
+    routes = []
+    for one in families:
+        argv = _linux_argv(vrf, table, one)
+        parsed = _load_json(_node_stdout(node, argv), " ".join(argv))
+        if not isinstance(parsed, list):
+            raise KernelRoutesError(
+                "ip route JSON is not a list: %s" % (" ".join(argv),)
+            )
+        routes.extend(parsed)
+    return routes
+
+
+def _freebsd_fib(table):
+    """Return the ``netstat -F`` argument, or None for the current FIB."""
+    if table is None:
+        return None
+    if isinstance(table, str) and table in ("main", "default"):
+        return None
+    if isinstance(table, bool) or not isinstance(table, (int, str)):
+        raise KernelRoutesError(
+            "FreeBSD routing tables are FIB numbers, not %r" % (table,)
+        )
+    if isinstance(table, int) or (isinstance(table, str) and table.isdigit()):
+        return str(table)
+    raise KernelRoutesError(
+        "FreeBSD routing tables are FIB numbers; %r is a Linux table name"
+        % (table,)
+    )
+
+
+def _freebsd_argv(vrf, table, family):
+    if vrf not in (None, "default"):
+        raise KernelRoutesError(
+            "FreeBSD selects a kernel routing table by FIB number; "
+            "pass table=<fib> instead of vrf=%r" % (vrf,)
+        )
+    argv = ["netstat", "--libxo", "json", "-rn"]
+    if family == "ipv4":
+        argv.extend(("-f", "inet"))
+    elif family == "ipv6":
+        argv.extend(("-f", "inet6"))
+    fib = _freebsd_fib(table)
+    if fib is not None:
+        argv.extend(("-F", fib))
+    return argv
+
+
+def _kernel_routes_freebsd(node, vrf, table, family):
+    argv = _freebsd_argv(vrf, table, family)
+    parsed = _load_json(_node_stdout(node, argv), " ".join(argv))
+    if parsed == []:
+        return []
+    return _routes_from_netstat(parsed, family)
+
+
+def _as_list(value):
+    if value is None:
+        return []
+    if isinstance(value, list):
+        return value
+    return [value]
+
+
+def _routes_from_netstat(payload, family):
+    """Convert ``netstat --libxo json -rn`` JSON to iproute2 route objects."""
+    try:
+        table = payload["statistics"]["route-information"]["route-table"]
+    except (KeyError, TypeError) as exc:
+        raise KernelRoutesError(
+            "netstat JSON is missing statistics/route-information/route-table"
+        ) from exc
+
+    routes = []
+    for fam in _as_list(table.get("rt-family")):
+        if not isinstance(fam, dict):
+            continue
+        ipfam = _FAMILY_BY_AF.get(fam.get("address-family"))
+        if ipfam is None:
+            continue
+        if family != "all" and ipfam != family:
+            continue
+        routes.extend(_collapse_family(_as_list(fam.get("rt-entry")), ipfam))
+    return routes
+
+
+def _collapse_family(entries, family):
+    """Merge ECMP members that share a destination and nexthop-group index."""
+    groups = []
+    position = {}
+    for entry in entries:
+        if not isinstance(entry, dict) or not entry.get("destination"):
+            continue
+        nhg = entry.get("nhg-kidx")
+        if nhg is not None:
+            key = (entry.get("destination"), str(nhg))
+            slot = position.get(key)
+            if slot is not None:
+                groups[slot].append(entry)
+                continue
+            position[key] = len(groups)
+        groups.append([entry])
+    return [_emit_route(group, family) for group in groups]
+
+
+def _normalize_dst(dest, family):
+    dest = _ZONE.sub("", dest)
+    if dest in ("default", "0.0.0.0/0", "::/0"):
+        return "default"
+    if family == "ipv4" and dest.endswith("/32"):
+        return dest[:-3]
+    if family == "ipv6" and dest.endswith("/128"):
+        return dest[:-4]
+    return dest
+
+
+def _normalize_gateway(gateway):
+    if not gateway or _LINK_GATEWAY.match(gateway):
+        return None
+    gateway = _ZONE.sub("", gateway)
+    if ":" in gateway or _IPV4_ADDR.match(gateway):
+        return gateway
+    return None
+
+
+def _route_type(entry):
+    pretty = entry.get("flags_pretty") or []
+    if isinstance(pretty, str):
+        pretty = pretty.split()
+    names = {str(flag).lower() for flag in pretty}
+    if "blackhole" in names:
+        return "blackhole"
+    if "reject" in names:
+        return "unreachable"
+    return None
+
+
+def _emit_route(group, family):
+    first = group[0]
+    route = {}
+    rtype = _route_type(first)
+    if rtype:
+        route["type"] = rtype
+    route["dst"] = _normalize_dst(first["destination"], family)
+    if len(group) == 1:
+        gateway = _normalize_gateway(first.get("gateway"))
+        if gateway:
+            route["gateway"] = gateway
+        dev = first.get("interface-name")
+        if dev:
+            route["dev"] = dev
+        return route
+
+    nexthops = []
+    for entry in group:
+        nh = {}
+        gateway = _normalize_gateway(entry.get("gateway"))
+        if gateway:
+            nh["gateway"] = gateway
+        dev = entry.get("interface-name")
+        if dev:
+            nh["dev"] = dev
+        if entry.get("weight") is not None:
+            nh["weight"] = entry["weight"]
+        nexthops.append(nh)
+    route["nexthops"] = nexthops
+    return route
diff --git a/tests/topotests/lib/test/test_kernel_routes.py b/tests/topotests/lib/test/test_kernel_routes.py
new file mode 100644
index 0000000000..7826447ae5
--- /dev/null
+++ b/tests/topotests/lib/test/test_kernel_routes.py
@@ -0,0 +1,325 @@
+# SPDX-License-Identifier: ISC
+"""Tests for kernel_routes()."""
+
+import json
+import os
+import sys
+
+CWD = os.path.dirname(os.path.realpath(__file__))
+sys.path.append(os.path.join(CWD, "../../"))
+
+# pylint: disable=C0413
+from lib.kernel_routes import (  # noqa: E402
+    KernelRoutesError,
+    kernel_routes,
+    _freebsd_argv,
+    _linux_argv,
+    _routes_from_netstat,
+)
+import pytest  # noqa: E402
+
+
+# Captured from `netstat --libxo json -rn` on a FreeBSD topotest jail.
+NETSTAT = json.loads(
+    r"""
+{"statistics": {"route-information": {"route-table": {"rt-family": [
+{"address-family":"Internet", "rt-entry": [
+{"destination":"10.0.1.0/24","gateway":"link#23","flags":"U", "flags_pretty": ["up"],"weight":1,"nhop-kidx":2,"interface-name":"r1-eth0"},
+{"destination":"10.0.1.1","gateway":"link#13","flags":"UHS", "flags_pretty": ["up","host","static"],"weight":1,"nhop-kidx":3,"interface-name":"lo0"},
+{"destination":"10.0.2.0/24","gateway":"10.0.3.3","flags":"UG1", "flags_pretty": ["up","gateway","proto1"],"weight":1,"nhop-kidx":6,"interface-name":"r1-eth1"},
+{"destination":"10.0.3.0/24","gateway":"link#29","flags":"U", "flags_pretty": ["up"],"weight":1,"nhop-kidx":4,"interface-name":"r1-eth1"},
+{"destination":"10.0.3.2","gateway":"link#13","flags":"UHS", "flags_pretty": ["up","host","static"],"weight":1,"nhop-kidx":5,"interface-name":"lo0"},
+{"destination":"10.0.10.0/24","gateway":"10.0.3.1","flags":"UG1", "flags_pretty": ["up","gateway","proto1"],"weight":1,"nhop-kidx":7,"interface-name":"r1-eth1"},
+{"destination":"127.0.0.1","gateway":"link#13","flags":"UH", "flags_pretty": ["up","host"],"weight":1,"nhop-kidx":1,"interface-name":"lo0"},
+{"destination":"172.16.0.0/24","gateway":"10.0.3.1","flags":"UG1", "flags_pretty": ["up","gateway","proto1"],"weight":1,"nhop-kidx":7,"interface-name":"r1-eth1"},
+{"destination":"172.16.1.0/24","gateway":"10.0.3.1","flags":"UG1", "flags_pretty": ["up","gateway","proto1"],"weight":1,"nhop-kidx":7,"interface-name":"r1-eth1"}
+]},
+{"address-family":"Internet6", "rt-entry": [
+{"destination":"::1","gateway":"link#13","flags":"UHS", "flags_pretty": ["up","host","static"],"weight":1,"nhop-kidx":1,"interface-name":"lo0"},
+{"destination":"1::1","gateway":"2001:db8:1::2","flags":"UGH1", "flags_pretty": ["up","gateway","host","proto1"],"weight":1,"nhg-kidx":16,"interface-name":"r1-eth0"},
+{"destination":"1::1","gateway":"2001:db8:1::3","flags":"UGH1", "flags_pretty": ["up","gateway","host","proto1"],"weight":1,"nhg-kidx":16,"interface-name":"r1-eth0"},
+{"destination":"1::1","gateway":"2001:db8:1::4","flags":"UGH1", "flags_pretty": ["up","gateway","host","proto1"],"weight":1,"nhg-kidx":16,"interface-name":"r1-eth0"},
+{"destination":"1::1","gateway":"2001:db8:1::5","flags":"UGH1", "flags_pretty": ["up","gateway","host","proto1"],"weight":1,"nhg-kidx":16,"interface-name":"r1-eth0"},
+{"destination":"2001:db8:1::/64","gateway":"link#23","flags":"U", "flags_pretty": ["up"],"weight":1,"nhop-kidx":5,"interface-name":"r1-eth0"},
+{"destination":"2001:db8:1::1","gateway":"link#13","flags":"UHS", "flags_pretty": ["up","host","static"],"weight":1,"nhop-kidx":4,"interface-name":"lo0"},
+{"destination":"2001:db8:2::/64","gateway":"fe80::6013:37ff:fea9:9ea0%r1-eth1","flags":"UG1", "flags_pretty": ["up","gateway","proto1"],"weight":1,"nhop-kidx":9,"interface-name":"r1-eth1"},
+{"destination":"2001:db8:3::/64","gateway":"link#29","flags":"U", "flags_pretty": ["up"],"weight":1,"nhop-kidx":7,"interface-name":"r1-eth1"},
+{"destination":"2001:db8:3::2","gateway":"link#13","flags":"UHS", "flags_pretty": ["up","host","static"],"weight":1,"nhop-kidx":6,"interface-name":"lo0"},
+{"destination":"2001:db8:100::/64","gateway":"fe80::382f:78ff:fe4a:846c%r1-eth1","flags":"UG1", "flags_pretty": ["up","gateway","proto1"],"weight":1,"nhop-kidx":8,"interface-name":"r1-eth1"},
+{"destination":"2001:db8:200::/64","gateway":"fe80::382f:78ff:fe4a:846c%r1-eth1","flags":"UG1", "flags_pretty": ["up","gateway","proto1"],"weight":1,"nhop-kidx":8,"interface-name":"r1-eth1"},
+{"destination":"2001:db8:300::/64","gateway":"fe80::382f:78ff:fe4a:846c%r1-eth1","flags":"UG1", "flags_pretty": ["up","gateway","proto1"],"weight":1,"nhop-kidx":8,"interface-name":"r1-eth1"},
+{"destination":"fe80::%lo0/64","gateway":"link#13","flags":"U", "flags_pretty": ["up"],"weight":1,"nhop-kidx":3,"interface-name":"lo0"},
+{"destination":"fe80::1%lo0","gateway":"link#13","flags":"UHS", "flags_pretty": ["up","host","static"],"weight":1,"nhop-kidx":2,"interface-name":"lo0"},
+{"destination":"fe80::%r1-eth0/64","gateway":"link#23","flags":"U", "flags_pretty": ["up"],"weight":1,"nhop-kidx":5,"interface-name":"r1-eth0"},
+{"destination":"fe80::c4e7:5cff:fe2a:dfbe%lo0","gateway":"link#13","flags":"UHS", "flags_pretty": ["up","host","static"],"weight":1,"nhop-kidx":4,"interface-name":"lo0"},
+{"destination":"fe80::%r1-eth1/64","gateway":"link#29","flags":"U", "flags_pretty": ["up"],"weight":1,"nhop-kidx":7,"interface-name":"r1-eth1"},
+{"destination":"fe80::84d1:85ff:fe5b:158d%lo0","gateway":"link#13","flags":"UHS", "flags_pretty": ["up","host","static"],"weight":1,"nhop-kidx":6,"interface-name":"lo0"}
+]}]}}}}
+"""
+)
+
+_LINUX_ONLY = (
+    "protocol",
+    "metric",
+    "flags",
+    "nhid",
+    "pref",
+    "scope",
+    "prefsrc",
+)
+
+
+class FakeNode:
+    def __init__(self, output):
+        self.output = output
+        self.cmds = []
+
+    def cmd_raises(self, argv):
+        self.cmds.append(list(argv))
+        if isinstance(self.output, list):
+            return self.output.pop(0)
+        return self.output
+
+
+def test_netstat_sample_matches_iproute_shape():
+    routes = _routes_from_netstat(NETSTAT, "all")
+
+    assert len(routes) == 25
+    for route in routes:
+        for key in _LINUX_ONLY:
+            assert key not in route
+        blob = json.dumps(route)
+        assert "link#" not in blob
+        assert "%" not in blob
+
+    assert {"dst": "10.0.1.0/24", "dev": "r1-eth0"} in routes
+    assert {
+        "dst": "10.0.2.0/24",
+        "gateway": "10.0.3.3",
+        "dev": "r1-eth1",
+    } in routes
+    assert {"dst": "10.0.1.1", "dev": "lo0"} in routes
+    assert {"dst": "127.0.0.1", "dev": "lo0"} in routes
+    assert {
+        "dst": "1::1",
+        "nexthops": [
+            {"gateway": "2001:db8:1::2", "dev": "r1-eth0", "weight": 1},
+            {"gateway": "2001:db8:1::3", "dev": "r1-eth0", "weight": 1},
+            {"gateway": "2001:db8:1::4", "dev": "r1-eth0", "weight": 1},
+            {"gateway": "2001:db8:1::5", "dev": "r1-eth0", "weight": 1},
+        ],
+    } in routes
+    assert {
+        "dst": "2001:db8:2::/64",
+        "gateway": "fe80::6013:37ff:fea9:9ea0",
+        "dev": "r1-eth1",
+    } in routes
+    assert {"dst": "fe80::/64", "dev": "lo0"} in routes
+    assert {"dst": "fe80::/64", "dev": "r1-eth0"} in routes
+    assert {"dst": "fe80::/64", "dev": "r1-eth1"} in routes
+    assert {"dst": "fe80::1", "dev": "lo0"} in routes
+
+    v4 = _routes_from_netstat(NETSTAT, "ipv4")
+    v6 = _routes_from_netstat(NETSTAT, "ipv6")
+    assert len(v4) == 9
+    assert len(v6) == 16
+    assert all(":" not in route["dst"] for route in v4)
+    assert all(":" in route["dst"] for route in v6)
+
+
+def test_netstat_normalizes_host_default_and_special_types():
+    payload = {
+        "statistics": {
+            "route-information": {
+                "route-table": {
+                    "rt-family": [
+                        {
+                            "address-family": "Internet",
+                            "rt-entry": [
+                                {
+                                    "destination": "0.0.0.0/0",
+                                    "gateway": "10.0.0.1",
+                                    "flags_pretty": ["up", "gateway"],
+                                    "weight": 1,
+                                    "interface-name": "eth0",
+                                },
+                                {
+                                    "destination": "192.0.2.1/32",
+                                    "gateway": "10.0.0.1",
+                                    "flags_pretty": ["up", "gateway", "host"],
+                                    "weight": 1,
+                                    "interface-name": "eth0",
+                                },
+                                {
+                                    "destination": "203.0.113.0/24",
+                                    "gateway": "lo0",
+                                    "flags_pretty": ["up", "blackhole"],
+                                    "interface-name": "lo0",
+                                },
+                                {
+                                    "destination": "198.51.100.0/24",
+                                    "flags_pretty": "reject",
+                                },
+                            ],
+                        },
+                        {
+                            "address-family": "Internet6",
+                            "rt-entry": {
+                                "destination": "2001:db8::1/128",
+                                "gateway": "fe80::1%eth0",
+                                "flags_pretty": ["up", "gateway", "host"],
+                                "interface-name": "eth0",
+                            },
+                        },
+                    ]
+                }
+            }
+        }
+    }
+    assert _routes_from_netstat(payload, "all") == [
+        {"dst": "default", "gateway": "10.0.0.1", "dev": "eth0"},
+        {"dst": "192.0.2.1", "gateway": "10.0.0.1", "dev": "eth0"},
+        {"type": "blackhole", "dst": "203.0.113.0/24", "dev": "lo0"},
+        {"type": "unreachable", "dst": "198.51.100.0/24"},
+        {"dst": "2001:db8::1", "gateway": "fe80::1", "dev": "eth0"},
+    ]
+
+
+def test_same_destination_without_nhg_stays_separate():
+    payload = {
+        "statistics": {
+            "route-information": {
+                "route-table": {
+                    "rt-family": {
+                        "address-family": "Internet6",
+                        "rt-entry": [
+                            {
+                                "destination": "fe80::/64",
+                                "gateway": "link#1",
+                                "interface-name": "lo0",
+                            },
+                            {
+                                "destination": "fe80::/64",
+                                "gateway": "link#2",
+                                "interface-name": "eth0",
+                            },
+                        ],
+                    }
+                }
+            }
+        }
+    }
+    assert _routes_from_netstat(payload, "ipv6") == [
+        {"dst": "fe80::/64", "dev": "lo0"},
+        {"dst": "fe80::/64", "dev": "eth0"},
+    ]
+
+
+def test_linux_command_and_passthrough(monkeypatch):
+    monkeypatch.setattr(sys, "platform", "linux")
+    shown = [
+        {
+            "dst": "1::1",
+            "nhid": 9922,
+            "protocol": "static",
+            "metric": 20,
+            "flags": [],
+            "pref": "medium",
+            "nexthops": [
+                {
+                    "gateway": "2001:db8::2",
+                    "dev": "eth0",
+                    "weight": 1,
+                    "flags": [],
+                }
+            ],
+        }
+    ]
+    node = FakeNode(json.dumps(shown))
+    assert kernel_routes(node, family="ipv6") == shown
+    assert node.cmds == [["ip", "-j", "-6", "route", "show"]]
+
+    node = FakeNode(["[]", json.dumps([{"dst": "default", "dev": "eth0"}])])
+    assert kernel_routes(node, vrf="vrf1", table=10, family="all") == [
+        {"dst": "default", "dev": "eth0"}
+    ]
+    assert node.cmds == [
+        ["ip", "-j", "-4", "route", "show", "vrf", "vrf1", "table", "10"],
+        ["ip", "-j", "-6", "route", "show", "vrf", "vrf1", "table", "10"],
+    ]
+
+
+def test_freebsd_command(monkeypatch):
+    monkeypatch.setattr(sys, "platform", "freebsd14")
+    node = FakeNode(json.dumps(NETSTAT))
+    routes = kernel_routes(node, family="ipv4")
+    assert node.cmds == [["netstat", "--libxo", "json", "-rn", "-f", "inet"]]
+    assert len(routes) == 9
+    assert routes[2] == {
+        "dst": "10.0.2.0/24",
+        "gateway": "10.0.3.3",
+        "dev": "r1-eth1",
+    }
+
+    node = FakeNode(json.dumps(NETSTAT))
+    kernel_routes(node, vrf="default", table=3, family="ipv6")
+    assert node.cmds == [
+        ["netstat", "--libxo", "json", "-rn", "-f", "inet6", "-F", "3"]
+    ]
+
+    node = FakeNode(json.dumps(NETSTAT))
+    kernel_routes(node, table="main", family="all")
+    assert node.cmds == [["netstat", "--libxo", "json", "-rn"]]
+
+    node = FakeNode("{}")
+    with pytest.raises(KernelRoutesError, match="vrf"):
+        kernel_routes(node, vrf="vrf1")
+    assert node.cmds == []
+
+    with pytest.raises(KernelRoutesError, match="FIB"):
+        kernel_routes(node, table="local")
+    assert node.cmds == []
+
+
+def test_run_fallback_quotes_the_command(monkeypatch):
+    monkeypatch.setattr(sys, "platform", "linux")
+
+    class RunOnly:
+        def __init__(self):
+            self.cmds = []
+
+        def run(self, command):
+            self.cmds.append(command)
+            return "[]"
+
+    node = RunOnly()
+    assert kernel_routes(node, vrf="vrf 1") == []
+    assert node.cmds == ["ip -j -4 route show vrf 'vrf 1'"]
+
+
+def test_argv_helpers_match_kernel_routes():
+    assert _linux_argv(None, None, "ipv4") == ["ip", "-j", "-4", "route", "show"]
+    assert _linux_argv("red", 100, "ipv6") == [
+        "ip",
+        "-j",
+        "-6",
+        "route",
+        "show",
+        "vrf",
+        "red",
+        "table",
+        "100",
+    ]
+    assert _freebsd_argv(None, None, "all") == ["netstat", "--libxo", "json", "-rn"]
+    assert _freebsd_argv(None, 0, "ipv4") == [
+        "netstat",
+        "--libxo",
+        "json",
+        "-rn",
+        "-f",
+        "inet",
+        "-F",
+        "0",
+    ]
+    with pytest.raises(KernelRoutesError):
+        kernel_routes(FakeNode(""), family="ethernet")
diff --git a/tests/topotests/lib/topotest.py b/tests/topotests/lib/topotest.py
index 644cb98bf9..0b41efe38a 100644
--- a/tests/topotests/lib/topotest.py
+++ b/tests/topotests/lib/topotest.py
@@ -36,6 +36,7 @@ from munet.base import commander, get_exec_path_host, Timeout
 from munet.testing.util import retry

 from lib import micronet
+from lib.kernel_routes import kernel_routes

 g_pytest_config = None