]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
cephadm: list-networks opt-in loopback and BGP route supplements
authorKobi Ginon <kginon@redhat.com>
Sat, 2 May 2026 13:14:48 +0000 (16:14 +0300)
committerKobi Ginon <kginon@redhat.com>
Mon, 18 May 2026 11:30:08 +0000 (14:30 +0300)
Fixes: https://tracker.ceph.com/issues/62195
Extend cephadm list-networks (and the cephadm mgr module options that pass
flags through to it) so hosts can expose loopback and BGP-derived routes
when operators need them, while keeping the default conservative for
orchestrator network discovery.

- Add cephadm flags --allow-lo-routes and --allow-bgp-routes (after the
  list-networks subcommand). Wire mgr options allow_lo_routes and
  allow_bgp_routes to the remote cephadm invocation.

- IPv4: build from ``ip route ls`` (scope link + src). When allow_lo_routes
  is true, merge extra lines from the same output via _parse_ipv4_lo_route
  (lo /32 without src, host-scoped routes including dummy-style /32 from
  76229). Always omit prefixes wholly inside 127.0.0.0/8. When
  allow_lo_routes is false, skip any route whose device is lo at parse time.
  When allow_bgp_routes is true, merge ``ip route ls proto bgp`` (ECMP
  nexthops and lo BGP /32), again respecting allow_lo_routes for lo.

- IPv6: build from ``ip -6 route ls`` and ``ip -6 addr ls``; omit ::1/128.
  When allow_lo_routes is false, skip routes on lo (including global /128 on
  lo). When allow_bgp_routes is true, merge ``ip -6 route ls proto bgp`` in
  the same way as IPv4, including lo BGP /128 when allow_lo_routes is true.

Signed-off-by: Kobi Ginon <kginon@redhat.com>
src/cephadm/cephadm.py
src/cephadm/cephadmlib/host_facts.py
src/cephadm/tests/test_networks.py
src/cephadm/tests/test_networks_lo_routes.py [new file with mode: 0644]
src/pybind/mgr/cephadm/module.py
src/pybind/mgr/cephadm/serve.py

index 626ceb54d54559eeb854bda8427554c76097fc63..b3133088961e3e03be9589b88d81a11d3c7c683b 100755 (executable)
@@ -5019,6 +5019,18 @@ def _get_parser():
 
     parser_list_networks = subparsers.add_parser(
         'list-networks', help='list IP networks')
+    parser_list_networks.add_argument(
+        '--allow-lo-routes',
+        action='store_true',
+        default=False,
+        help='Reserved for future filtering of loopback-related routes',
+    )
+    parser_list_networks.add_argument(
+        '--allow-bgp-routes',
+        action='store_true',
+        default=False,
+        help='Reserved for future filtering of BGP-derived routes',
+    )
     parser_list_networks.set_defaults(func=command_list_networks)
 
     parser_list_rdma = subparsers.add_parser(
index ec85430d2aa4b91bb1a57d513f0d095c465a239c..c4acd4a6b69818782d25455c5633d8c34190f9e6 100644 (file)
@@ -849,14 +849,18 @@ class HostFacts:
 def list_networks(ctx):
     # type: (CephadmContext) -> Dict[str,Dict[str, Set[str]]]
 
-    # sadly, 18.04's iproute2 4.15.0-2ubun doesn't support the -j flag,
-    # so we'll need to use a regex to parse 'ip' command output.
-    #
-    # out, _, _ = call_throws(['ip', '-j', 'route', 'ls'])
-    # j = json.loads(out)
-    # for x in j:
-    res = _list_ipv4_networks(ctx)
-    res.update(_list_ipv6_networks(ctx))
+    # Main route tables use text ``ip route ls`` (regex parsers). BGP supplements
+    # use ``ip -j route ls proto bgp`` on supported platforms (iproute2 >= 4.18).
+    allow_lo = getattr(ctx, 'allow_lo_routes', False)
+    allow_bgp = getattr(ctx, 'allow_bgp_routes', False)
+    res = _list_ipv4_networks(
+        ctx, allow_lo_routes=allow_lo, allow_bgp_routes=allow_bgp
+    )
+    res.update(
+        _list_ipv6_networks(
+            ctx, allow_lo_routes=allow_lo, allow_bgp_routes=allow_bgp
+        )
+    )
     return res
 
 
@@ -905,21 +909,50 @@ def list_rdma(ctx: CephadmContext) -> List[Dict[str, str]]:
     return result
 
 
-def _list_ipv4_networks(
-    ctx: CephadmContext,
-) -> Dict[str, Dict[str, Set[str]]]:
-    execstr: Optional[str] = find_executable('ip')
-    if not execstr:
-        raise FileNotFoundError("unable to find 'ip' command")
-    out, _, _ = call_throws(
-        ctx,
-        [execstr, 'route', 'ls'],
-        verbosity=CallVerbosity.QUIET_UNLESS_ERROR,
+_IPV4_LOOPBACK_SPACE = ipaddress.ip_network('127.0.0.0/8')
+_IPV6_LOOPBACK_HOST = ipaddress.ip_network('::1/128')
+
+
+def _is_ipv4_loopback(net_s: str) -> bool:
+    """True if *net_s* is an IPv4 prefix wholly within host loopback 127.0.0.0/8."""
+    try:
+        n = ipaddress.ip_network(net_s, strict=False)
+    except ValueError:
+        return False
+    return (
+        n.version == 4
+        and n.network_address in _IPV4_LOOPBACK_SPACE
+        and n.broadcast_address in _IPV4_LOOPBACK_SPACE
     )
-    return _parse_ipv4_route(out)
 
 
-def _parse_ipv4_route(out: str) -> Dict[str, Dict[str, Set[str]]]:
+def _is_ipv6_loopback(net_s: str) -> bool:
+    """True if *net_s* is exactly the host loopback prefix ::1/128."""
+    try:
+        n = ipaddress.ip_network(net_s, strict=False)
+    except ValueError:
+        return False
+    if n.version != 6:
+        return False
+    return n == _IPV6_LOOPBACK_HOST
+
+
+def _merge_ipv4_network_dicts(
+    dst: Dict[str, Dict[str, Set[str]]], add: Dict[str, Dict[str, Set[str]]]
+) -> None:
+    """Merge *add* into *dst* (nested sets)."""
+    for net, ifaces in add.items():
+        if net not in dst:
+            dst[net] = {}
+        for iface, ips in ifaces.items():
+            if iface not in dst[net]:
+                dst[net][iface] = set()
+            dst[net][iface].update(ips)
+
+
+def _parse_ipv4_route(
+    out: str, allow_lo_routes: bool = False
+) -> Dict[str, Dict[str, Set[str]]]:
     r = {}  # type: Dict[str, Dict[str, Set[str]]]
     p = re.compile(
         r'^(\S+) (?:via \S+)? ?dev (\S+) (.*)scope link (.*)src (\S+)'
@@ -931,19 +964,214 @@ def _parse_ipv4_route(out: str) -> Dict[str, Dict[str, Set[str]]]:
         net = m[0][0]
         if '/' not in net:  # aggregate /32 mask for single host sub-networks
             net += '/32'
-        iface = m[0][1]
+        # Strip iproute2 ``@`` suffix (e.g. ``brx.0@eno1`` → ``brx.0``).
+        iface = m[0][1].split('@')[0]
+        if iface == 'lo' and not allow_lo_routes:
+            continue
         ip = m[0][4]
+        if _is_ipv4_loopback(net):
+            continue
+        if net not in r:
+            r[net] = {}
+        if iface not in r[net]:
+            r[net][iface] = set()
+        r[net][iface].add(ip)
+    return r
+
+
+def _parse_ipv4_lo_route(out: str) -> Dict[str, Dict[str, Set[str]]]:
+    """``lo`` /32 without ``src``, and ``scope host`` + ``src`` (e.g. dummy /32)."""
+    r = {}  # type: Dict[str, Dict[str, Set[str]]]
+    p_lo_no_src = re.compile(
+        r'^(\S+) (?:via \S+)? ?dev (lo(?:@\S+)?) (.*)scope (?:link|host)\s*(?!src\b).*$'
+    )
+    p_host_src = re.compile(
+        r'^(\S+) (?:via \S+)? ?dev (\S+) (.*)scope host (.*)src (\S+)'
+    )
+
+    def put(net: str, iface: str, ip: str) -> None:
+        if _is_ipv4_loopback(net):
+            return
         if net not in r:
             r[net] = {}
         if iface not in r[net]:
             r[net][iface] = set()
         r[net][iface].add(ip)
+
+    for line in out.splitlines():
+        m = p_lo_no_src.findall(line)
+        if not m:
+            continue
+        net = m[0][0]
+        if '/' not in net:
+            net += '/32'
+        iface = m[0][1].split('@')[0]
+        try:
+            n = ipaddress.ip_network(net, strict=False)
+        except ValueError:
+            continue
+        if n.prefixlen != 32:
+            continue
+        put(net, iface, str(n.network_address))
+
+    for line in out.splitlines():
+        m = p_host_src.findall(line)
+        if not m:
+            continue
+        net = m[0][0]
+        if '/' not in net:
+            net += '/32'
+        iface = m[0][1].split('@')[0]
+        ip = m[0][4]
+        put(net, iface, ip)
+
     return r
 
 
+def _route_iface_name(dev: str) -> str:
+    """Strip iproute2 ``child@parent`` interface suffix."""
+    return dev.split('@')[0]
+
+
+def _route_dst_to_network(dst: str, version: int) -> str:
+    if '/' in dst:
+        return dst
+    return f'{dst}/{"32" if version == 4 else "128"}'
+
+
+def _parse_bgp_routes_json(
+    out: str,
+    version: int,
+    allow_lo_routes: bool = False,
+) -> Dict[str, Dict[str, Set[str]]]:
+    """Parse JSON from ``ip -j route ls proto bgp`` (or ``ip -6 -j route ls proto bgp``)."""
+    r: Dict[str, Dict[str, Set[str]]] = {}
+    try:
+        routes = json.loads(out)
+    except (json.JSONDecodeError, TypeError):
+        logger.debug('failed to parse ip -j route output as JSON')
+        return r
+    if not isinstance(routes, list):
+        return r
+
+    is_v4 = version == 4
+    host_plen = 32 if is_v4 else 128
+
+    def put(net: str, iface: str, ip: str) -> None:
+        if _is_ipv4_loopback(net) if is_v4 else _is_ipv6_loopback(net):
+            return
+        if net not in r:
+            r[net] = {}
+        if iface not in r[net]:
+            r[net][iface] = set()
+        r[net][iface].add(ip)
+
+    for route in routes:
+        if not isinstance(route, dict):
+            continue
+        dst = route.get('dst')
+        if not dst:
+            continue
+
+        net = _route_dst_to_network(dst, version)
+        nexthops = route.get('nexthops')
+        prefsrc = route.get('prefsrc')
+
+        if nexthops and prefsrc:
+            for nh in nexthops:
+                if not isinstance(nh, dict):
+                    continue
+                dev = nh.get('dev')
+                if not dev:
+                    continue
+                iface = _route_iface_name(dev)
+                if iface == 'lo' and not allow_lo_routes:
+                    continue
+                put(net, iface, prefsrc)
+            continue
+
+        dev = route.get('dev', '')
+        iface = _route_iface_name(dev) if dev else ''
+
+        if iface == 'lo' and not prefsrc:
+            if not allow_lo_routes:
+                continue
+            try:
+                n = ipaddress.ip_network(net, strict=False)
+            except ValueError:
+                continue
+            if n.prefixlen != host_plen:
+                continue
+            put(net, 'lo', str(n.network_address))
+            continue
+
+        if prefsrc and iface:
+            if iface == 'lo' and not allow_lo_routes:
+                continue
+            put(net, iface, prefsrc)
+
+    return r
+
+
+def _parse_ipv4_bgp_route(
+    out: str, allow_lo_routes: bool = False
+) -> Dict[str, Dict[str, Set[str]]]:
+    """Parse JSON from ``ip -j route ls proto bgp``."""
+    return _parse_bgp_routes_json(out, 4, allow_lo_routes)
+
+
+def _parse_ipv6_bgp_route(
+    out: str, allow_lo_routes: bool = False
+) -> Dict[str, Dict[str, Set[str]]]:
+    """Parse JSON from ``ip -6 -j route ls proto bgp``."""
+    return _parse_bgp_routes_json(out, 6, allow_lo_routes)
+
+
+def _list_ipv4_networks(
+    ctx: CephadmContext,
+    allow_lo_routes: bool = False,
+    allow_bgp_routes: bool = False,
+) -> Dict[str, Dict[str, Set[str]]]:
+    """IPv4 networks from ``ip route ls`` (and optional ``proto bgp`` supplement).
+
+    When *allow_lo_routes* is false, routes on ``lo`` are ignored by the parsers.
+    When true, ``_parse_ipv4_lo_route`` supplements the base table.
+    """
+    execstr: Optional[str] = find_executable('ip')
+    if not execstr:
+        raise FileNotFoundError("unable to find 'ip' command")
+    out, _, _ = call_throws(
+        ctx,
+        [execstr, 'route', 'ls'],
+        verbosity=CallVerbosity.QUIET_UNLESS_ERROR,
+    )
+    res = _parse_ipv4_route(out, allow_lo_routes)
+    if allow_lo_routes:
+        _merge_ipv4_network_dicts(res, _parse_ipv4_lo_route(out))
+    if allow_bgp_routes:
+        bgp_out, _, _ = call_throws(
+            ctx,
+            [execstr, '-j', 'route', 'ls', 'proto', 'bgp'],
+            verbosity=CallVerbosity.QUIET_UNLESS_ERROR,
+        )
+        _merge_ipv4_network_dicts(
+            res, _parse_ipv4_bgp_route(bgp_out, allow_lo_routes)
+        )
+    return res
+
+
 def _list_ipv6_networks(
     ctx: CephadmContext,
+    allow_lo_routes: bool = False,
+    allow_bgp_routes: bool = False,
 ) -> Dict[str, Dict[str, Set[str]]]:
+    """IPv6 networks from ``ip -6 route`` and ``ip -6 addr``.
+
+    When *allow_lo_routes* is false, routes on ``lo`` are ignored so loopback globals
+    are not listed. When *allow_bgp_routes* is true, ``ip -6 route ls proto bgp``
+    is merged into the same shape as the main table (respecting *allow_lo_routes*
+    for ``lo`` BGP paths).
+    """
     execstr: Optional[str] = find_executable('ip')
     if not execstr:
         raise FileNotFoundError("unable to find 'ip' command")
@@ -957,11 +1185,23 @@ def _list_ipv6_networks(
         [execstr, '-6', 'addr', 'ls'],
         verbosity=CallVerbosity.QUIET_UNLESS_ERROR,
     )
-    return _parse_ipv6_route(routes, ips)
+    res = _parse_ipv6_route(routes, ips, allow_lo_routes)
+    if allow_bgp_routes:
+        bgp_out, _, _ = call_throws(
+            ctx,
+            [execstr, '-6', '-j', 'route', 'ls', 'proto', 'bgp'],
+            verbosity=CallVerbosity.QUIET_UNLESS_ERROR,
+        )
+        _merge_ipv4_network_dicts(
+            res, _parse_ipv6_bgp_route(bgp_out, allow_lo_routes)
+        )
+    return res
 
 
 def _parse_ipv6_route(
-    routes: str, ips: str
+    routes: str,
+    ips: str,
+    allow_lo_routes: bool = False,
 ) -> Dict[str, Dict[str, Set[str]]]:
     r = {}  # type: Dict[str, Dict[str, Set[str]]]
     route_p = re.compile(
@@ -976,8 +1216,10 @@ def _parse_ipv6_route(
         net = m[0][0]
         if '/' not in net:  # aggregate /128 mask for single host sub-networks
             net += '/128'
-        iface = m[0][1]
-        if iface == 'lo':  # skip loopback devices
+        iface = m[0][1].split('@')[0]
+        if iface == 'lo' and not allow_lo_routes:
+            continue
+        if _is_ipv6_loopback(net):
             continue
         if net not in r:
             r[net] = {}
index 5cc5fd00dc8d30d809078ed776deb408eec73ba9..f854dfab1fb7689e01c7db1dbb89126678382930 100644 (file)
@@ -48,6 +48,8 @@ class TestEndPoint:
 
 
 class TestCommandListNetworks:
+    # fmt: off
+    # Keep table-style parametrization unchanged for reviewable diffs (see PR discussion).
     @pytest.mark.parametrize("test_input, expected", [
         (
             dedent("""
@@ -106,9 +108,11 @@ class TestCommandListNetworks:
             }
         ),
     ])
+    # fmt: on
     def test_parse_ipv4_route(self, test_input, expected):
         assert _parse_ipv4_route(test_input) == expected
 
+    # fmt: off
     @pytest.mark.parametrize("test_routes, test_ips, expected", [
         (
             dedent("""
@@ -236,7 +240,8 @@ class TestCommandListNetworks:
             ::1 dev lo proto kernel metric 256 pref medium
             fe80::/64 dev ceph-brx proto kernel metric 256 pref medium
             fe80::/64 dev brx.0 proto kernel metric 256 pref medium
-            default via fe80::327c:5e00:6487:71e0 dev enp3s0f1 proto ra metric 1024 expires 1790sec hoplimit 64 pref medium            """),
+            default via fe80::327c:5e00:6487:71e0 dev enp3s0f1 proto ra metric 1024 expires 1790sec hoplimit 64 pref medium
+            """),
             dedent("""
             1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 state UNKNOWN qlen 1000
                 inet6 ::1/128 scope host
@@ -259,9 +264,38 @@ class TestCommandListNetworks:
             }
         ),
     ])
+    # fmt: on
     def test_parse_ipv6_route(self, test_routes, test_ips, expected):
         assert _parse_ipv6_route(test_routes, test_ips) == expected
 
+    def test_parse_ipv6_route_includes_lo_global_not_slaac(self):
+        test_routes = dedent(
+            """
+        ::1 dev lo proto kernel metric 256 pref medium
+        2620:52:0:1304::71 dev lo proto kernel metric 256 pref medium
+        """
+        )
+        test_ips = dedent(
+            """
+        1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 state UNKNOWN qlen 1000
+            inet6 ::1/128 scope host
+               valid_lft forever preferred_lft forever
+            inet6 2620:52:0:1304::71/128 scope global
+               valid_lft forever preferred_lft forever
+        """
+        )
+        expected = {
+            '2620:52:0:1304::71/128': {'lo': {'2620:52:0:1304::71'}},
+        }
+        assert (
+            _parse_ipv6_route(test_routes, test_ips, allow_lo_routes=False)
+            == {}
+        )
+        assert (
+            _parse_ipv6_route(test_routes, test_ips, allow_lo_routes=True)
+            == expected
+        )
+
     @mock.patch('cephadmlib.net_utils.read_file')
     def test_get_ipv6_addr(self, _read_file):
         proc_net_if_net6 = """fe80000000000000505400fffe347999 02 40 20 80     eth0
@@ -277,8 +311,18 @@ fe80000000000000505400fffe04c154 03 40 20 80     eth1
 
     @mock.patch('cephadmlib.host_facts.call_throws')
     @mock.patch('cephadmlib.host_facts.find_executable')
-    def test_command_list_networks(self, _find_exe, _call_throws, cephadm_fs, capsys):
-        _call_throws.return_value = ('10.4.0.1 dev tun0 proto kernel scope link src 10.4.0.2 metric 50\n', '', '')
+    def test_command_list_networks(
+        self, _find_exe, _call_throws, cephadm_fs, capsys
+    ):
+        _call_throws.side_effect = [
+            (
+                '10.4.0.1 dev tun0 proto kernel scope link src 10.4.0.2 metric 50\n',
+                '',
+                '',
+            ),
+            ('', '', ''),
+            ('', '', ''),
+        ]
         _find_exe.return_value = 'ip'
         with with_cephadm_ctx([]) as ctx:
             _cephadm.command_list_networks(ctx)
diff --git a/src/cephadm/tests/test_networks_lo_routes.py b/src/cephadm/tests/test_networks_lo_routes.py
new file mode 100644 (file)
index 0000000..4e02c97
--- /dev/null
@@ -0,0 +1,349 @@
+import json
+from textwrap import dedent
+
+from cephadmlib.host_facts import (
+    _merge_ipv4_network_dicts,
+    _parse_ipv4_bgp_route,
+    _parse_ipv4_lo_route,
+    _parse_ipv4_route,
+    _parse_ipv6_bgp_route,
+)
+
+
+def _merged_ipv4(out: str, *, allow_lo: bool = False):
+    r = _parse_ipv4_route(out, allow_lo)
+    if allow_lo:
+        _merge_ipv4_network_dicts(r, _parse_ipv4_lo_route(out))
+    return r
+
+
+class TestLoopbackRouteParsing:
+    def test_parse_ipv4_route_omits_127_on_lo(self):
+        test_input = dedent(
+            """
+            192.168.1.0/24 dev eth0 proto kernel scope link src 192.168.1.1
+            127.0.0.0/8 dev lo proto kernel scope link src 127.0.0.1
+            """
+        )
+        expected = {'192.168.1.0/24': {'eth0': {'192.168.1.1'}}}
+        assert _parse_ipv4_route(test_input) == expected
+
+    def test_parse_ipv4_route_includes_non_loopback_on_lo(self):
+        test_input = dedent(
+            """
+            192.168.1.0/24 dev eth0 proto kernel scope link src 192.168.1.1
+            10.168.100.0/24 dev lo proto kernel scope link src 10.168.100.10
+            """
+        )
+        expected_with_lo = {
+            '192.168.1.0/24': {'eth0': {'192.168.1.1'}},
+            '10.168.100.0/24': {'lo': {'10.168.100.10'}},
+        }
+        assert _parse_ipv4_route(test_input) == {'192.168.1.0/24': {'eth0': {'192.168.1.1'}}}
+        assert _parse_ipv4_route(test_input, allow_lo_routes=True) == expected_with_lo
+
+    def test_parse_ipv4_lo_route_adds_lo_host_route_without_src(self):
+        # ``ip route add …/32 dev lo proto bgp`` prints no ``src`` line.
+        test_input = dedent(
+            """
+            192.168.100.0/24 dev ens3 proto kernel scope link src 192.168.100.100
+            10.168.100.10 dev lo proto bgp scope link
+            """
+        )
+        expected = {
+            '192.168.100.0/24': {'ens3': {'192.168.100.100'}},
+            '10.168.100.10/32': {'lo': {'10.168.100.10'}},
+        }
+        assert _merged_ipv4(test_input, allow_lo=True) == expected
+
+    def test_parse_ipv4_lo_route_scope_host_on_lo(self):
+        test_input = dedent(
+            """
+            192.168.100.0/24 dev ens3 proto kernel scope link src 192.168.100.100
+            10.10.10.90 dev lo proto kernel scope host
+            """
+        )
+        assert _parse_ipv4_route(test_input) == {
+            '192.168.100.0/24': {'ens3': {'192.168.100.100'}},
+        }
+        assert _merged_ipv4(test_input, allow_lo=True) == {
+            '192.168.100.0/24': {'ens3': {'192.168.100.100'}},
+            '10.10.10.90/32': {'lo': {'10.10.10.90'}},
+        }
+
+    def test_parse_ipv4_lo_route_bgp_scope_host_on_lo(self):
+        test_input = dedent(
+            """
+            10.10.10.10 dev lo proto bgp scope host
+            """
+        )
+        assert _parse_ipv4_route(test_input) == {}
+        assert _parse_ipv4_lo_route(test_input) == {
+            '10.10.10.10/32': {'lo': {'10.10.10.10'}},
+        }
+
+    def test_parse_ipv4_lo_route_dummy_scope_host(self):
+        # Host-scoped /32 on dummy (tracker.ceph.com/issues/76229).
+        test_input = dedent(
+            """
+            192.168.100.0/24 dev ens3 proto kernel scope link src 192.168.100.100
+            10.1.0.40 dev dummy0 proto kernel scope host src 10.1.0.40
+            """
+        )
+        expected = {
+            '192.168.100.0/24': {'ens3': {'192.168.100.100'}},
+            '10.1.0.40/32': {'dummy0': {'10.1.0.40'}},
+        }
+        assert _merged_ipv4(test_input, allow_lo=True) == expected
+
+    def test_parse_ipv4_bgp_route_ecmp_nexthops(self):
+        # From ``ip -j route ls proto bgp`` on a BGP host (ceph-node-0).
+        bgp_input = json.dumps(
+            [
+                {
+                    'dst': '192.168.100.5',
+                    'protocol': 'bgp',
+                    'prefsrc': '192.168.100.11',
+                    'flags': [],
+                    'nexthops': [
+                        {
+                            'gateway': '169.254.0.1',
+                            'dev': 'ens1f0np0',
+                            'weight': 1,
+                            'flags': ['onlink'],
+                        },
+                        {
+                            'gateway': '169.254.0.1',
+                            'dev': 'ens1f1np1',
+                            'weight': 1,
+                            'flags': ['onlink'],
+                        },
+                    ],
+                },
+                {
+                    'dst': '192.168.100.6',
+                    'protocol': 'bgp',
+                    'prefsrc': '192.168.100.11',
+                    'flags': [],
+                    'nexthops': [
+                        {
+                            'gateway': '169.254.0.1',
+                            'dev': 'ens1f0np0',
+                            'weight': 1,
+                            'flags': ['onlink'],
+                        },
+                        {
+                            'gateway': '169.254.0.1',
+                            'dev': 'ens1f1np1',
+                            'weight': 1,
+                            'flags': ['onlink'],
+                        },
+                    ],
+                },
+                {
+                    'dst': '10.0.1.11',
+                    'protocol': 'bgp',
+                    'prefsrc': '10.0.1.11',
+                    'flags': [],
+                    'nexthops': [
+                        {
+                            'gateway': '169.254.0.1',
+                            'dev': 'ens1f0np0',
+                            'weight': 1,
+                            'flags': ['onlink'],
+                        },
+                        {
+                            'gateway': '169.254.0.1',
+                            'dev': 'ens1f1np1',
+                            'weight': 1,
+                            'flags': ['onlink'],
+                        },
+                    ],
+                },
+            ]
+        )
+        expected = {
+            '192.168.100.5/32': {
+                'ens1f0np0': {'192.168.100.11'},
+                'ens1f1np1': {'192.168.100.11'},
+            },
+            '192.168.100.6/32': {
+                'ens1f0np0': {'192.168.100.11'},
+                'ens1f1np1': {'192.168.100.11'},
+            },
+            '10.0.1.11/32': {
+                'ens1f0np0': {'10.0.1.11'},
+                'ens1f1np1': {'10.0.1.11'},
+            },
+        }
+        assert _parse_ipv4_bgp_route(bgp_input) == expected
+
+    def test_parse_ipv4_bgp_route_ecmp_without_protocol_field(self):
+        # ``ip -j route ls proto bgp`` may omit ``protocol`` on filtered output.
+        bgp_input = json.dumps(
+            [
+                {
+                    'dst': '192.168.100.5',
+                    'prefsrc': '192.168.100.11',
+                    'metric': 20,
+                    'flags': [],
+                    'nexthops': [
+                        {
+                            'gateway': '169.254.0.1',
+                            'dev': 'ens1f0np0',
+                            'weight': 1,
+                            'flags': ['onlink'],
+                        },
+                        {
+                            'gateway': '169.254.0.1',
+                            'dev': 'ens1f1np1',
+                            'weight': 1,
+                            'flags': ['onlink'],
+                        },
+                    ],
+                },
+                {
+                    'dst': '192.168.100.6',
+                    'prefsrc': '192.168.100.11',
+                    'metric': 20,
+                    'flags': [],
+                    'nexthops': [
+                        {
+                            'gateway': '169.254.0.1',
+                            'dev': 'ens1f0np0',
+                            'weight': 1,
+                            'flags': ['onlink'],
+                        },
+                        {
+                            'gateway': '169.254.0.1',
+                            'dev': 'ens1f1np1',
+                            'weight': 1,
+                            'flags': ['onlink'],
+                        },
+                    ],
+                },
+            ]
+        )
+        expected = {
+            '192.168.100.5/32': {
+                'ens1f0np0': {'192.168.100.11'},
+                'ens1f1np1': {'192.168.100.11'},
+            },
+            '192.168.100.6/32': {
+                'ens1f0np0': {'192.168.100.11'},
+                'ens1f1np1': {'192.168.100.11'},
+            },
+        }
+        assert _parse_ipv4_bgp_route(bgp_input) == expected
+
+    def test_parse_ipv4_bgp_route_lo_without_src(self):
+        bgp_input = json.dumps(
+            [
+                {
+                    'dst': '10.168.100.10',
+                    'dev': 'lo',
+                    'protocol': 'bgp',
+                    'scope': 'link',
+                    'flags': [],
+                }
+            ]
+        )
+        assert _parse_ipv4_bgp_route(bgp_input) == {}
+        assert _parse_ipv4_bgp_route(bgp_input, allow_lo_routes=True) == {
+            '10.168.100.10/32': {'lo': {'10.168.100.10'}},
+        }
+
+    def test_parse_ipv4_bgp_route_lo_from_full_table(self):
+        # ``ip -j route ls`` entry for ``10.10.10.10 dev lo proto bgp``.
+        bgp_input = json.dumps(
+            [
+                {
+                    'dst': '10.10.10.10',
+                    'dev': 'lo',
+                    'protocol': 'bgp',
+                    'scope': 'link',
+                    'flags': [],
+                }
+            ]
+        )
+        assert _parse_ipv4_bgp_route(bgp_input, allow_lo_routes=True) == {
+            '10.10.10.10/32': {'lo': {'10.10.10.10'}},
+        }
+
+    def test_parse_ipv6_bgp_route_ecmp_nexthops(self):
+        bgp_input = json.dumps(
+            [
+                {
+                    'dst': '2001:db8:100::5',
+                    'protocol': 'bgp',
+                    'prefsrc': '2001:db8:100::11',
+                    'metric': 20,
+                    'pref': 'medium',
+                    'flags': [],
+                    'nexthops': [
+                        {
+                            'gateway': 'fe80::1',
+                            'dev': 'ens1f0np0',
+                            'weight': 1,
+                            'flags': [],
+                        },
+                        {
+                            'gateway': 'fe80::2',
+                            'dev': 'ens1f1np1',
+                            'weight': 1,
+                            'flags': [],
+                        },
+                    ],
+                },
+                {
+                    'dst': '2001:db8:200::1:11',
+                    'protocol': 'bgp',
+                    'prefsrc': '2001:db8:200::1:11',
+                    'metric': 20,
+                    'pref': 'medium',
+                    'flags': [],
+                    'nexthops': [
+                        {
+                            'gateway': 'fe80::a',
+                            'dev': 'ens1f0np0',
+                            'weight': 1,
+                            'flags': [],
+                        },
+                        {
+                            'gateway': 'fe80::b',
+                            'dev': 'ens1f1np1',
+                            'weight': 1,
+                            'flags': [],
+                        },
+                    ],
+                },
+            ]
+        )
+        expected = {
+            '2001:db8:100::5/128': {
+                'ens1f0np0': {'2001:db8:100::11'},
+                'ens1f1np1': {'2001:db8:100::11'},
+            },
+            '2001:db8:200::1:11/128': {
+                'ens1f0np0': {'2001:db8:200::1:11'},
+                'ens1f1np1': {'2001:db8:200::1:11'},
+            },
+        }
+        assert _parse_ipv6_bgp_route(bgp_input) == expected
+
+    def test_parse_ipv6_bgp_route_lo_without_src(self):
+        bgp_input = json.dumps(
+            [
+                {
+                    'dst': '2001:db8:bad:10::10',
+                    'dev': 'lo',
+                    'protocol': 'bgp',
+                    'scope': 'link',
+                    'flags': [],
+                }
+            ]
+        )
+        assert _parse_ipv6_bgp_route(bgp_input) == {}
+        assert _parse_ipv6_bgp_route(bgp_input, allow_lo_routes=True) == {
+            '2001:db8:bad:10::10/128': {'lo': {'2001:db8:bad:10::10'}},
+        }
index 0e47dcd8018f05dcdda17d5d9d90b2a45c894472..7dfb72381f19b90cdd9e560b4cd09c0dc6b0a5d2 100644 (file)
@@ -199,6 +199,23 @@ class CephadmOrchestrator(orchestrator.Orchestrator, MgrModule):
             default=1 * 60,
             desc='Seconds for which to cache host facts data',
         ),
+        Option(
+            'allow_lo_routes',
+            type='bool',
+            default=False,
+            desc='If true, cephadm list-networks is run with --allow-lo-routes so '
+            'loopback (lo) routes are included in host network facts; if false, '
+            'they are omitted (default).',
+        ),
+        Option(
+            'allow_bgp_routes',
+            type='bool',
+            default=False,
+            desc='If true, cephadm list-networks is run with --allow-bgp-routes so '
+            'BGP routes from ``ip route ls proto bgp`` and '
+            '``ip -6 route ls proto bgp`` are merged into host network facts; if '
+            'false (default), only the main IPv4 and IPv6 tables are used.',
+        ),
         Option(
             'host_check_interval',
             type='secs',
@@ -543,6 +560,8 @@ class CephadmOrchestrator(orchestrator.Orchestrator, MgrModule):
             self.device_cache_timeout = 0
             self.daemon_cache_timeout = 0
             self.facts_cache_timeout = 0
+            self.allow_lo_routes = False
+            self.allow_bgp_routes = False
             self.host_check_interval = 0
             self.stray_daemon_check_interval = 0
             self.max_count_per_host = 0
index 1f1b5c2fd6f8258dfb469bb65d0c9cecfc488045..c37ea6f271d1261620d1aa4428208926ffbecc44 100644 (file)
@@ -438,9 +438,20 @@ class CephadmServe:
 
     def _refresh_host_networks(self, host: str) -> Optional[str]:
         try:
+            list_net_args: List[str] = []
+            if self.mgr.allow_lo_routes:
+                list_net_args.append('--allow-lo-routes')
+            if self.mgr.allow_bgp_routes:
+                list_net_args.append('--allow-bgp-routes')
             with self.mgr.async_timeout_handler(host, 'cephadm list-networks'):
                 networks = self.mgr.wait_async(self._run_cephadm_json(
-                    host, 'mon', 'list-networks', [], no_fsid=True, log_output=self.mgr.log_refresh_metadata))
+                    host,
+                    'mon',
+                    'list-networks',
+                    list_net_args,
+                    no_fsid=True,
+                    log_output=self.mgr.log_refresh_metadata,
+                ))
         except OrchestratorError as e:
             return str(e)