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