From: Kobi Ginon Date: Sat, 2 May 2026 13:14:48 +0000 (+0300) Subject: cephadm: list-networks opt-in loopback and BGP route supplements X-Git-Url: http://git-server-git.apps.pok.os.sepia.ceph.com/?a=commitdiff_plain;h=43a62944af86377a1e2143e3139087cb41ff4365;p=ceph.git cephadm: list-networks opt-in loopback and BGP route supplements 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 --- diff --git a/src/cephadm/cephadm.py b/src/cephadm/cephadm.py index 626ceb54d545..b3133088961e 100755 --- a/src/cephadm/cephadm.py +++ b/src/cephadm/cephadm.py @@ -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( diff --git a/src/cephadm/cephadmlib/host_facts.py b/src/cephadm/cephadmlib/host_facts.py index ec85430d2aa4..c4acd4a6b698 100644 --- a/src/cephadm/cephadmlib/host_facts.py +++ b/src/cephadm/cephadmlib/host_facts.py @@ -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] = {} diff --git a/src/cephadm/tests/test_networks.py b/src/cephadm/tests/test_networks.py index 5cc5fd00dc8d..f854dfab1fb7 100644 --- a/src/cephadm/tests/test_networks.py +++ b/src/cephadm/tests/test_networks.py @@ -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: 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: 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 index 000000000000..4e02c972e356 --- /dev/null +++ b/src/cephadm/tests/test_networks_lo_routes.py @@ -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'}}, + } diff --git a/src/pybind/mgr/cephadm/module.py b/src/pybind/mgr/cephadm/module.py index 0e47dcd8018f..7dfb72381f19 100644 --- a/src/pybind/mgr/cephadm/module.py +++ b/src/pybind/mgr/cephadm/module.py @@ -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 diff --git a/src/pybind/mgr/cephadm/serve.py b/src/pybind/mgr/cephadm/serve.py index 1f1b5c2fd6f8..c37ea6f271d1 100644 --- a/src/pybind/mgr/cephadm/serve.py +++ b/src/pybind/mgr/cephadm/serve.py @@ -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)