From 01049cb2e44560945364dfe2790187373fb09f06 Mon Sep 17 00:00:00 2001 From: Narasimhan Ganapathiraman Date: Fri, 7 Aug 2026 22:36:30 +0000 Subject: [PATCH] Add UpperRegionalHub BGP anchor-prefix and table-map tests Add three test files exercising the ANCHOR_PREFIX/SELECTIVE_ROUTE_DOWNLOAD table-map mechanism for the UpperRegionalHub device type, complementing the corresponding sonic-buildimage allowlist/table-map changes: - test_urh_anchor_prefix.py: end-to-end DUT-local verification of the ANCHOR_PREFIX CONFIG_DB -> BGP table-map -> community-tagged aggregate route pipeline for UpperRegionalHub. On topologies where the static minigraph-driven general/peer-group.conf.j2 template doesn't render (e.g. dynamic-neighbor KVM t0), falls back to applying the equivalent route-maps directly via vtysh so the underlying bgpcfgd production code path is still exercised end-to-end. - test_bgp_table_map.py: generic, parametrized FIB-filtering tests across multiple device types (UpperSpineRouter, UpperRegionalHub) validating that table-map suppression only removes routes from the FIB while the BGP RIB retains them. - test_bgp_table_map_device_type.py: device-type gating test confirming the table-map is rendered only for device types where it's expected (SpineRouter+UpstreamLC, UpperSpineRouter, UpperRegionalHub) and absent otherwise. These tests are topology-agnostic (t0, t1, t2, lrh, urh, any) and exercise the same bgpcfgd/PrefixListMgr and FRR template code paths introduced in sonic-buildimage PR #28893 (add UpperRegionalHub to the ANCHOR_PREFIX allowlist and SELECTIVE_ROUTE_DOWNLOAD table-map condition). Note: the UpperRegionalHub-specific paths in these tests depend on sonic-buildimage PR #28893 landing; until then, DUTs built from current master will not have gained ANCHOR_PREFIX/table-map support for this device type. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Narasimhan Ganapathiraman --- tests/bgp/test_bgp_table_map.py | 695 ++++++++++++++++ tests/bgp/test_bgp_table_map_device_type.py | 110 +++ tests/bgp/test_urh_anchor_prefix.py | 826 ++++++++++++++++++++ 3 files changed, 1631 insertions(+) create mode 100644 tests/bgp/test_bgp_table_map.py create mode 100644 tests/bgp/test_bgp_table_map_device_type.py create mode 100644 tests/bgp/test_urh_anchor_prefix.py diff --git a/tests/bgp/test_bgp_table_map.py b/tests/bgp/test_bgp_table_map.py new file mode 100644 index 00000000000..4e158571038 --- /dev/null +++ b/tests/bgp/test_bgp_table_map.py @@ -0,0 +1,695 @@ +""" +Test BGP table-map FIB filtering (SELECTIVE_ROUTE_DOWNLOAD) + +Two complementary test groups: + +1. Community-based filtering (SELECTIVE_ROUTE_DOWNLOAD_V4/V6): + Routes tagged with LOCAL_ANCHOR_ROUTE_COMMUNITY are held in BGP RIB but not + installed into FIB/ASIC. Requires UpperSpineRouter or SpineRouter+UpstreamLC + device type — the FRR template generates the table-map on BGP docker restart. + +2. Prefix-list based filtering (custom route-map, from Work Item 37441388): + A custom PREFIX_LIST + ROUTE_MAP is applied directly as table-map via vtysh. + Matches the configuration in Mohan Nanduri's work item example: + ip prefix-list BLOCK_20_NET seq 5 permit 20.0.0.0/8 le 32 + route-map FIB_FILTER deny 10 → match ip address prefix-list BLOCK_20_NET + route-map FIB_FILTER permit 1000 + address-family ipv4 unicast → table-map FIB_FILTER + These tests do NOT require a specific device type. + +Related: Work Item 37441388 (SELECTIVE_ROUTE_DOWNLOAD) + +Note: Device type validation tests are in test_bgp_table_map_device_type.py. +""" + +import json +import logging +import pytest +import yaml + +from tests.common.helpers.assertions import pytest_assert, pytest_require +from tests.common.utilities import wait_until +from tests.bgp.bgp_helpers import update_routes, get_exabgp_port + +pytestmark = [ + pytest.mark.topology('t0', 't1', 't2', 'lrh', 'urh', 'any'), + pytest.mark.skip_check_dut_health +] + +logger = logging.getLogger(__name__) + + +@pytest.fixture(scope="module", autouse=True) +def skip_multi_asic(duthosts, enum_dut_hostname): + """Skip this module on multi-ASIC DUTs. + + Helpers here (vtysh, get_bgp_asn, apply/remove_table_map, is_route_in_rib + excepted - that one is already per-ASIC aware) hard-code the single-ASIC + 'bgp' container/namespace (e.g. 'docker exec bgp ...' with no per-ASIC + 'bgpN'/'-n asicN' targeting), so on multi-ASIC platforms they'd target the + wrong or nonexistent container and fail before exercising table-map + behavior at all. + """ + duthost = duthosts[enum_dut_hostname] + pytest_require( + not duthost.is_multi_asic, + "test_bgp_table_map module requires a single-ASIC DUT " + "(helpers assume a single 'bgp' container)" + ) + + +EXABGP_BASE_PORT = 5000 +EXABGP_BASE_PORT_V6 = 6000 +CONSTANTS_FILE = "/etc/sonic/constants.yml" + +# Community-based filtering (SELECTIVE_ROUTE_DOWNLOAD) +BLOCKED_PREFIXES_V4 = ["20.5.10.0/24", "20.10.20.0/24", "20.100.50.0/24"] +PERMITTED_PREFIXES_V4 = ["10.1.0.0/16", "10.5.10.0/24", "192.168.100.0/24"] +BLOCKED_PREFIXES_V6 = ["2001:db8:20:5::/64", "2001:db8:20:10::/64"] +PERMITTED_PREFIXES_V6 = ["2001:db8:10:1::/64", "2001:db8:fe::/64"] + +# Prefix-list based filtering — mirrors Work Item 37441388 example config +# IPv4: block all subnets within 20.0.0.0/8 (le 32) +PL_BLOCK_NET_V4 = "BLOCK_20_NET" +PL_BLOCK_PREFIX_V4 = "20.0.0.0/8" +PL_RMAP_V4 = "FIB_FILTER_PL" +PL_BLOCKED_ROUTE_V4 = "20.5.10.0/24" +PL_PERMITTED_ROUTE_V4 = "10.1.0.0/16" + +# IPv6: block exactly-/64 subnets within 2a01:20::/32 (ge 64 le 64) +PL_BLOCK_NET_V6 = "BLOCK_TEST_V6" +PL_BLOCK_PREFIX_V6 = "2a01:20::/32" +PL_RMAP_V6 = "FIB_FILTER_PL_V6" +PL_BLOCKED_ROUTE_V6 = "2a01:20:0:1::/64" +PL_PERMITTED_ROUTE_V6 = "2001:db8:10:1::/64" + + +# ============================================================ +# Helpers +# ============================================================ + +def get_anchor_community(duthost): + """Read local_anchor_route_community from constants.yml on the DUT.""" + pytest_require( + duthost.stat(path=CONSTANTS_FILE)["stat"]["exists"], + "constants.yml not found on DUT, skipping test" + ) + constants = yaml.safe_load(duthost.shell("cat {}".format(CONSTANTS_FILE))["stdout"]) + try: + return constants["constants"]["bgp"]["local_anchor_route_community"] + except KeyError: + pytest.skip("local_anchor_route_community not defined in constants.yml") + + +def set_device_type(duthost, device_type, subtype=None): + """Set DEVICE_METADATA type and subtype in CONFIG_DB.""" + duthost.shell("redis-cli -n 4 HSET 'DEVICE_METADATA|localhost' type {}".format(device_type)) + if subtype: + duthost.shell("redis-cli -n 4 HSET 'DEVICE_METADATA|localhost' subtype {}".format(subtype)) + else: + duthost.shell("redis-cli -n 4 HDEL 'DEVICE_METADATA|localhost' subtype", + module_ignore_errors=True) + + +def restart_bgp_and_wait(duthost): + """Restart the BGP docker(s) so FRR templates are regenerated, then wait for sessions. + + 'docker restart' bypasses systemd, so bgp.service's Restart=always drop-in + fires its own follow-up restart on top of ours, silently consuming a slot + in systemd's StartLimitBurst counter (default 3 per 20 min) on every call. + A test module that calls this helper more than a few times per run can + trip bgp.service into a spurious 'start-limit-hit' failure even though the + container itself is healthy. Clearing the counter immediately beforehand + keeps repeated calls from accumulating against that limit. + """ + if duthost.is_multi_asic: + for asic_index in duthost.get_frontend_asic_ids(): + duthost.shell("sudo systemctl reset-failed bgp{}".format(asic_index), module_ignore_errors=True) + duthost.shell("docker restart bgp{}".format(asic_index)) + else: + duthost.shell("sudo systemctl reset-failed bgp", module_ignore_errors=True) + duthost.shell("docker restart bgp") + config_facts = duthost.config_facts(host=duthost.hostname, source="running")["ansible_facts"] + bgp_neighbors = config_facts.get("BGP_NEIGHBOR", {}) + # 180s was cutting it close in practice: full-table T1 neighbors (thousands + # of routes each) can take upward of 3-4 minutes to reach Established on + # this KVM environment. On the native URH topology (6 confederation + # neighbors carrying a real ~100k-entry full table each, observed with + # load average ~4 on the KVM host) even the follow-up 300s budget can be + # too tight, so bump to 480s to avoid flaking on genuine (slow-but-healthy) + # convergence rather than a real failure. + pytest_assert( + wait_until(480, 10, 30, duthost.check_bgp_session_state, bgp_neighbors), + "BGP sessions did not re-establish after BGP docker restart" + ) + + +def bgpcfgd_is_running(duthost): + """Return True if the bgpcfgd process inside the bgp docker is still alive (didn't crash).""" + out = duthost.shell( + "docker exec bgp supervisorctl status bgpcfgd", module_ignore_errors=True + )["stdout"] + return "RUNNING" in out + + +def _bgp_docker_responsive(duthost): + """Return True if the bgp docker is up and vtysh/bgpcfgd are responsive. + + Deliberately does NOT check neighbor session state. Some device types + (e.g. LeafRouter) render a peer-group template that is fundamentally + incompatible with this native URH confederation topology's real peers + (drops the per-neighbor fast timers this topology's peers require), so + sessions never re-establish under that device type on this testbed - + that is expected/unrelated to what device-type-gating tests actually check. + """ + out = duthost.shell("docker ps --filter name=bgp --format '{{.Status}}'", + module_ignore_errors=True)["stdout"] + if "Up" not in out: + return False + vtysh_out = duthost.shell("docker exec bgp vtysh -c 'show version'", module_ignore_errors=True) + return vtysh_out["rc"] == 0 and bgpcfgd_is_running(duthost) + + +def restart_bgp_and_wait_responsive(duthost): + """Restart the bgp docker and wait only for it to come back up and be responsive. + + Use this instead of restart_bgp_and_wait() when the test intentionally + applies a device type whose BGP sessions are not expected to (re)converge + on this topology - e.g. a disallowed/filtered 'LeafRouter' probe. Session + convergence is irrelevant there: the test only cares whether bgpcfgd + renders config (e.g. table-map presence/absence) correctly, which only + requires bgpd/bgpcfgd to be up and processing CONFIG_DB. + """ + duthost.shell("sudo systemctl reset-failed bgp", module_ignore_errors=True) + duthost.shell("docker restart bgp") + pytest_assert( + wait_until(60, 5, 10, _bgp_docker_responsive, duthost), + "bgp docker did not come back up/responsive after restart" + ) + + +def is_route_in_rib(duthost, prefix, ip_version=4): + """Return True if prefix is in BGP RIB on all frontend ASICs.""" + ip_ver = "ipv4" if ip_version == 4 else "ipv6" + for asic_index in duthost.get_frontend_asic_ids(): + asic_ns = "-n asic{}".format(asic_index) if duthost.is_multi_asic else "" + cmd = "vtysh {} -c 'show bgp {} {}'".format(asic_ns, ip_ver, prefix) + output = duthost.shell(cmd, module_ignore_errors=True)["stdout"] + if "Network not in table" in output or not output.strip(): + return False + return True + + +def is_route_in_fib(duthost, prefix): + """Return True if prefix is installed in FIB (APPL_DB ROUTE_TABLE) on all frontend ASICs.""" + for asic_index in duthost.get_frontend_asic_ids(): + asic_ns = "-n asic{}".format(asic_index) if duthost.is_multi_asic else "" + cmd = "sonic-db-cli {} APPL_DB hgetall \"ROUTE_TABLE:{}\"".format(asic_ns, prefix) + output = duthost.shell(cmd, module_ignore_errors=True)["stdout"].strip().replace("'", '"') + route_info = json.loads(output) if output else {} + if not route_info or route_info.get("blackhole") == "true": + return False + return True + + +# ============================================================ +# Module-level Fixtures +# ============================================================ + +@pytest.fixture(scope="module") +def anchor_community(duthosts, enum_dut_hostname): + """Read LOCAL_ANCHOR_ROUTE_COMMUNITY value from DUT constants.yml.""" + duthost = duthosts[enum_dut_hostname] + return get_anchor_community(duthost) + + +def _has_template_table_map(duthost): + """Return True if bgpd.conf already contains table-map (generated by FRR template).""" + result = duthost.shell( + "docker exec bgp grep -q 'table-map SELECTIVE_ROUTE_DOWNLOAD_V4' /etc/frr/bgpd.conf 2>/dev/null", + module_ignore_errors=True + ) + return result["rc"] == 0 + + +def _apply_selective_route_download_vtysh(duthost): + """ + Apply SELECTIVE_ROUTE_DOWNLOAD_V4/V6 route-maps and table-map via vtysh. + + Used as a fallback when the FRR template doesn't generate the table-map + (e.g., KVM testbeds using bgpcfgd dynamic neighbor configuration instead + of peer-group.conf.j2 templates). + """ + asn = get_bgp_asn(duthost) + community = get_anchor_community(duthost) + + # Define community-list + vtysh(duthost, "bgp community-list standard LOCAL_ANCHOR_ROUTE_COMMUNITY permit {}".format(community)) + + # Define route-maps + vtysh(duthost, "route-map SELECTIVE_ROUTE_DOWNLOAD_V4 deny 10", + "match community LOCAL_ANCHOR_ROUTE_COMMUNITY") + vtysh(duthost, "route-map SELECTIVE_ROUTE_DOWNLOAD_V4 permit 1000") + vtysh(duthost, "route-map SELECTIVE_ROUTE_DOWNLOAD_V6 deny 10", + "match community LOCAL_ANCHOR_ROUTE_COMMUNITY") + vtysh(duthost, "route-map SELECTIVE_ROUTE_DOWNLOAD_V6 permit 1000") + + # Apply as table-map + apply_table_map(duthost, asn, "SELECTIVE_ROUTE_DOWNLOAD_V4", ip_version=4) + apply_table_map(duthost, asn, "SELECTIVE_ROUTE_DOWNLOAD_V6", ip_version=6) + logger.info("Applied SELECTIVE_ROUTE_DOWNLOAD_V4/V6 table-map via vtysh (template fallback)") + + +def _remove_selective_route_download_vtysh(duthost): + """Remove the vtysh-applied table-map and route-maps (cleanup for fallback path).""" + asn = get_bgp_asn(duthost) + remove_table_map(duthost, asn, "SELECTIVE_ROUTE_DOWNLOAD_V4", ip_version=4) + remove_table_map(duthost, asn, "SELECTIVE_ROUTE_DOWNLOAD_V6", ip_version=6) + vtysh(duthost, "no route-map SELECTIVE_ROUTE_DOWNLOAD_V4") + vtysh(duthost, "no route-map SELECTIVE_ROUTE_DOWNLOAD_V6") + vtysh(duthost, "no bgp community-list standard LOCAL_ANCHOR_ROUTE_COMMUNITY") + + +@pytest.fixture(scope="module", params=["UpperSpineRouter", "UpperRegionalHub"]) +def setup_table_map_device_type(request, duthosts, enum_dut_hostname): + """ + Set device type to UpperSpineRouter or UpperRegionalHub (both parametrized) + so FRR templates generate `table-map SELECTIVE_ROUTE_DOWNLOAD_V4/V6` in + address-family config. + + On topologies where bgpd.conf is generated from bgpd.main.conf.j2 only + (e.g., KVM t1-lag using bgpcfgd dynamic neighbors), the template does not + render peer-group.conf.j2, so table-map is not written to bgpd.conf. + In that case, we fall back to applying the route-map and table-map directly + via vtysh so the filtering logic is still exercised. + + Restores original device type and removes any vtysh-applied config after + the module completes. + """ + duthost = duthosts[enum_dut_hostname] + device_type = request.param + + original_type = duthost.shell( + "redis-cli -n 4 HGET 'DEVICE_METADATA|localhost' type", + module_ignore_errors=True + )["stdout"].strip() or "ToRRouter" + original_subtype = duthost.shell( + "redis-cli -n 4 HGET 'DEVICE_METADATA|localhost' subtype", + module_ignore_errors=True + )["stdout"].strip() or None + + used_vtysh_fallback = False + try: + logger.info("Setting device type to {} to enable table-map".format(device_type)) + set_device_type(duthost, device_type) + restart_bgp_and_wait(duthost) + + if _has_template_table_map(duthost): + logger.info("table-map generated by FRR template in bgpd.conf") + else: + logger.info("table-map not in bgpd.conf (bgpcfgd topology) — applying via vtysh") + _apply_selective_route_download_vtysh(duthost) + used_vtysh_fallback = True + + yield duthost + finally: + if used_vtysh_fallback: + _remove_selective_route_download_vtysh(duthost) + logger.info("Restoring device type to {}/{}".format(original_type, original_subtype)) + set_device_type(duthost, original_type, original_subtype) + restart_bgp_and_wait(duthost) + logger.info("Device type restored") + + +@pytest.fixture(scope="module") +def exabgp_setup(duthosts, nbrhosts, tbinfo, enum_dut_hostname): + """Get PTF IP, ExaBGP ports, and next-hop IPs for route injection.""" + duthost = duthosts[enum_dut_hostname] + ptf_ip = tbinfo["ptf_ip"] + + exabgp_ports, _ = get_exabgp_port(duthost, nbrhosts, tbinfo, EXABGP_BASE_PORT, is_random=True) + exabgp_ports_v6, _ = get_exabgp_port(duthost, nbrhosts, tbinfo, EXABGP_BASE_PORT_V6, is_random=True) + + cfg_props = tbinfo["topo"]["properties"]["configuration_properties"]["common"] + nhipv4 = cfg_props.get("nhipv4", "10.10.246.254") + nhipv6 = cfg_props.get("nhipv6", "fc0a::ff") + + return { + "ptf_ip": ptf_ip, + "exabgp_port": exabgp_ports[0], + "exabgp_port_v6": exabgp_ports_v6[0], + "nhipv4": nhipv4, + "nhipv6": nhipv6, + } + + +# ============================================================ +# Tests: FIB Filtering +# ============================================================ + +def test_table_map_basic_deny(duthosts, enum_dut_hostname, + setup_table_map_device_type, exabgp_setup, anchor_community): + """ + Route WITH LOCAL_ANCHOR_ROUTE_COMMUNITY is received into BGP RIB but blocked from FIB. + + SELECTIVE_ROUTE_DOWNLOAD_V4 seq 10: deny community LOCAL_ANCHOR_ROUTE_COMMUNITY. + Route must be visible in 'show bgp ipv4 ' but absent from APPL_DB ROUTE_TABLE. + """ + duthost = duthosts[enum_dut_hostname] + prefix = BLOCKED_PREFIXES_V4[0] + route = {"prefix": prefix, "nexthop": exabgp_setup["nhipv4"], "community": anchor_community} + + try: + update_routes("announce", exabgp_setup["ptf_ip"], exabgp_setup["exabgp_port"], route) + + # Wait for route to reach BGP RIB (confirms ExaBGP session and BGP UPDATE worked) + pytest_assert( + wait_until(60, 3, 5, is_route_in_rib, duthost, prefix, 4), + "Route {} not received in BGP RIB".format(prefix) + ) + + # Route must NOT be installed in FIB — table-map blocks it + pytest_assert( + not is_route_in_fib(duthost, prefix), + "Route {} carrying anchor community should be blocked from FIB by table-map".format(prefix) + ) + logger.info("PASS: Route {} with anchor community blocked from FIB".format(prefix)) + finally: + update_routes("withdraw", exabgp_setup["ptf_ip"], exabgp_setup["exabgp_port"], route) + + +def test_table_map_basic_permit(duthosts, enum_dut_hostname, + setup_table_map_device_type, exabgp_setup, anchor_community): + """ + Route WITHOUT anchor community is received into BGP RIB and installed in FIB. + + SELECTIVE_ROUTE_DOWNLOAD_V4 seq 1000: permit (fallthrough for all other routes). + Route must be visible in both 'show bgp ipv4' and APPL_DB ROUTE_TABLE. + """ + duthost = duthosts[enum_dut_hostname] + prefix = PERMITTED_PREFIXES_V4[0] + route = {"prefix": prefix, "nexthop": exabgp_setup["nhipv4"]} # no community + + try: + update_routes("announce", exabgp_setup["ptf_ip"], exabgp_setup["exabgp_port"], route) + + pytest_assert( + wait_until(60, 3, 5, is_route_in_rib, duthost, prefix, 4), + "Route {} not received in BGP RIB".format(prefix) + ) + pytest_assert( + wait_until(30, 3, 0, is_route_in_fib, duthost, prefix), + "Route {} without anchor community should be installed in FIB".format(prefix) + ) + logger.info("PASS: Route {} without anchor community installed in FIB".format(prefix)) + finally: + update_routes("withdraw", exabgp_setup["ptf_ip"], exabgp_setup["exabgp_port"], route) + + +def test_table_map_ipv6(duthosts, enum_dut_hostname, + setup_table_map_device_type, exabgp_setup, anchor_community): + """ + IPv6 route WITH anchor community is blocked from FIB by SELECTIVE_ROUTE_DOWNLOAD_V6. + + Same community-based filtering applies for address-family ipv6. + """ + duthost = duthosts[enum_dut_hostname] + prefix = BLOCKED_PREFIXES_V6[0] + route = {"prefix": prefix, "nexthop": exabgp_setup["nhipv6"], "community": anchor_community} + + try: + update_routes("announce", exabgp_setup["ptf_ip"], exabgp_setup["exabgp_port_v6"], route) + + pytest_assert( + wait_until(60, 3, 5, is_route_in_rib, duthost, prefix, 6), + "IPv6 route {} not received in BGP RIB".format(prefix) + ) + pytest_assert( + not is_route_in_fib(duthost, prefix), + "IPv6 route {} with anchor community should be blocked from FIB".format(prefix) + ) + logger.info("PASS: IPv6 route {} with anchor community blocked from FIB".format(prefix)) + finally: + update_routes("withdraw", exabgp_setup["ptf_ip"], exabgp_setup["exabgp_port_v6"], route) + + +def test_table_map_mixed_filtering(duthosts, enum_dut_hostname, + setup_table_map_device_type, exabgp_setup, anchor_community): + """ + Multiple routes simultaneously: blocked (with anchor community) and permitted (no community). + + Verifies that table-map correctly handles each prefix independently based on + community attribute, matching the dRH use case of selectively installing routes. + """ + duthost = duthosts[enum_dut_hostname] + ptf_ip = exabgp_setup["ptf_ip"] + port = exabgp_setup["exabgp_port"] + nhip = exabgp_setup["nhipv4"] + + blocked_routes = [ + {"prefix": p, "nexthop": nhip, "community": anchor_community} + for p in BLOCKED_PREFIXES_V4 + ] + permitted_routes = [ + {"prefix": p, "nexthop": nhip} + for p in PERMITTED_PREFIXES_V4 + ] + all_routes = blocked_routes + permitted_routes + + try: + for route in all_routes: + update_routes("announce", ptf_ip, port, route) + + # Wait for all routes to appear in RIB + for prefix in BLOCKED_PREFIXES_V4 + PERMITTED_PREFIXES_V4: + pytest_assert( + wait_until(60, 3, 5, is_route_in_rib, duthost, prefix, 4), + "Route {} not received in BGP RIB".format(prefix) + ) + + # Blocked routes must NOT be in FIB + for prefix in BLOCKED_PREFIXES_V4: + pytest_assert( + not is_route_in_fib(duthost, prefix), + "Blocked route {} should not be installed in FIB".format(prefix) + ) + + # Permitted routes must be in FIB + for prefix in PERMITTED_PREFIXES_V4: + pytest_assert( + wait_until(30, 3, 0, is_route_in_fib, duthost, prefix), + "Permitted route {} should be installed in FIB".format(prefix) + ) + + logger.info("PASS: Mixed filtering verified ({} blocked, {} permitted)".format( + len(BLOCKED_PREFIXES_V4), len(PERMITTED_PREFIXES_V4))) + finally: + for route in all_routes: + update_routes("withdraw", ptf_ip, port, route) + + +# ============================================================ +# Helpers: Prefix-list based filtering via vtysh +# ============================================================ + +def vtysh(duthost, *commands): + """Run a sequence of vtysh commands inside 'configure terminal' on the BGP container.""" + cmd_args = " ".join(["-c '{}'".format(c) for c in ["configure terminal"] + list(commands)]) + duthost.shell("docker exec bgp vtysh {}".format(cmd_args)) + + +def get_bgp_asn(duthost): + """Return the DUT's BGP ASN from running config.""" + output = duthost.shell("docker exec bgp vtysh -c 'show running-config' | grep 'router bgp'")["stdout"] + for line in output.splitlines(): + line = line.strip() + if line.startswith("router bgp"): + return line.split()[2] + pytest.fail("Could not determine BGP ASN from running config") + + +def apply_table_map(duthost, asn, rmap_name, ip_version=4): + """Apply table-map to BGP address-family via vtysh.""" + af = "address-family ipv4 unicast" if ip_version == 4 else "address-family ipv6 unicast" + vtysh(duthost, "router bgp {}".format(asn), af, "table-map {}".format(rmap_name)) + + +def remove_table_map(duthost, asn, rmap_name, ip_version=4): + """Remove table-map from BGP address-family via vtysh.""" + af = "address-family ipv4 unicast" if ip_version == 4 else "address-family ipv6 unicast" + vtysh(duthost, "router bgp {}".format(asn), af, "no table-map {}".format(rmap_name)) + + +def configure_prefix_list_vtysh(duthost, name, seq, action, prefix, ge=None, le=None): + """Create an ip/ipv6 prefix-list entry via vtysh.""" + af = "ipv6" if ":" in prefix else "ip" + ge_le = "" + if ge is not None: + ge_le += " ge {}".format(ge) + if le is not None: + ge_le += " le {}".format(le) + vtysh(duthost, "{} prefix-list {} seq {} {} {}{}".format( + af, name, seq, action, prefix, ge_le)) + + +def configure_route_map_vtysh(duthost, name, seq, action, match_prefix_list=None, ip_version=4): + """Create a route-map entry with optional prefix-list match via vtysh.""" + vtysh(duthost, "route-map {} {} {}".format(name, action, seq)) + if match_prefix_list: + match_cmd = ("match ip address prefix-list {}" if ip_version == 4 + else "match ipv6 address prefix-list {}").format(match_prefix_list) + vtysh(duthost, "route-map {} {} {}".format(name, action, seq), match_cmd) + + +def remove_prefix_list_vtysh(duthost, name, ip_version=4): + """Remove all entries of an ip/ipv6 prefix-list via vtysh.""" + af = "ipv6" if ip_version == 6 else "ip" + vtysh(duthost, "no {} prefix-list {}".format(af, name)) + + +def remove_route_map_vtysh(duthost, name): + """Remove all entries of a route-map via vtysh.""" + vtysh(duthost, "no route-map {}".format(name)) + + +# ============================================================ +# Fixture: Prefix-list based table-map setup +# ============================================================ + +@pytest.fixture(scope="function") +def setup_prefix_list_table_map(duthosts, enum_dut_hostname, exabgp_setup): + """ + Configure a prefix-list + route-map and apply as table-map directly via vtysh. + Mirrors Work Item 37441388 example: + ip prefix-list BLOCK_20_NET seq 5 permit 20.0.0.0/8 le 32 + route-map FIB_FILTER_PL deny 10 → match ip address prefix-list BLOCK_20_NET + route-map FIB_FILTER_PL permit 1000 + address-family ipv4 unicast → table-map FIB_FILTER_PL + + Does NOT require a specific device type — table-map is applied directly. + """ + duthost = duthosts[enum_dut_hostname] + asn = get_bgp_asn(duthost) + + try: + # IPv4: block 20.0.0.0/8 le 32 + configure_prefix_list_vtysh(duthost, PL_BLOCK_NET_V4, 5, "permit", PL_BLOCK_PREFIX_V4, le=32) + configure_route_map_vtysh(duthost, PL_RMAP_V4, 10, "deny", PL_BLOCK_NET_V4, ip_version=4) + configure_route_map_vtysh(duthost, PL_RMAP_V4, 1000, "permit", ip_version=4) + apply_table_map(duthost, asn, PL_RMAP_V4, ip_version=4) + + # IPv6: block 2a01:20::/32 exactly /64 (ge 64 le 64) + configure_prefix_list_vtysh(duthost, PL_BLOCK_NET_V6, 10, "permit", PL_BLOCK_PREFIX_V6, ge=64, le=64) + configure_route_map_vtysh(duthost, PL_RMAP_V6, 10, "deny", PL_BLOCK_NET_V6, ip_version=6) + configure_route_map_vtysh(duthost, PL_RMAP_V6, 1000, "permit", ip_version=6) + apply_table_map(duthost, asn, PL_RMAP_V6, ip_version=6) + + logger.info("Prefix-list table-map configured (IPv4: {}, IPv6: {})".format( + PL_RMAP_V4, PL_RMAP_V6)) + yield duthost + finally: + remove_table_map(duthost, asn, PL_RMAP_V4, ip_version=4) + remove_table_map(duthost, asn, PL_RMAP_V6, ip_version=6) + remove_route_map_vtysh(duthost, PL_RMAP_V4) + remove_route_map_vtysh(duthost, PL_RMAP_V6) + remove_prefix_list_vtysh(duthost, PL_BLOCK_NET_V4, ip_version=4) + remove_prefix_list_vtysh(duthost, PL_BLOCK_NET_V6, ip_version=6) + logger.info("Prefix-list table-map cleaned up") + + +# ============================================================ +# Tests: Prefix-list based filtering (Work Item 37441388) +# ============================================================ + +def test_table_map_prefix_list_deny(duthosts, enum_dut_hostname, + setup_prefix_list_table_map, exabgp_setup): + """ + IPv4 route matching prefix-list BLOCK_20_NET is blocked from FIB. + + Config (from Work Item 37441388): + ip prefix-list BLOCK_20_NET seq 5 permit 20.0.0.0/8 le 32 + route-map FIB_FILTER_PL deny 10 → match ip address prefix-list BLOCK_20_NET + route-map FIB_FILTER_PL permit 1000 + table-map FIB_FILTER_PL + + 20.5.10.0/24 is within 20.0.0.0/8 → deny → in RIB, NOT in FIB. + """ + duthost = duthosts[enum_dut_hostname] + route = {"prefix": PL_BLOCKED_ROUTE_V4, "nexthop": exabgp_setup["nhipv4"]} + + try: + update_routes("announce", exabgp_setup["ptf_ip"], exabgp_setup["exabgp_port"], route) + + pytest_assert( + wait_until(60, 3, 5, is_route_in_rib, duthost, PL_BLOCKED_ROUTE_V4, 4), + "Route {} not received in BGP RIB".format(PL_BLOCKED_ROUTE_V4) + ) + pytest_assert( + not is_route_in_fib(duthost, PL_BLOCKED_ROUTE_V4), + "Route {} matches BLOCK_20_NET — should be blocked from FIB by table-map".format( + PL_BLOCKED_ROUTE_V4) + ) + logger.info("PASS: {} blocked from FIB by prefix-list table-map".format(PL_BLOCKED_ROUTE_V4)) + finally: + update_routes("withdraw", exabgp_setup["ptf_ip"], exabgp_setup["exabgp_port"], route) + + +def test_table_map_prefix_list_permit(duthosts, enum_dut_hostname, + setup_prefix_list_table_map, exabgp_setup): + """ + IPv4 route NOT matching prefix-list BLOCK_20_NET is installed in FIB. + + 10.1.0.0/16 is outside 20.0.0.0/8 → hits permit 1000 → in RIB AND in FIB. + """ + duthost = duthosts[enum_dut_hostname] + route = {"prefix": PL_PERMITTED_ROUTE_V4, "nexthop": exabgp_setup["nhipv4"]} + + try: + update_routes("announce", exabgp_setup["ptf_ip"], exabgp_setup["exabgp_port"], route) + + pytest_assert( + wait_until(60, 3, 5, is_route_in_rib, duthost, PL_PERMITTED_ROUTE_V4, 4), + "Route {} not received in BGP RIB".format(PL_PERMITTED_ROUTE_V4) + ) + pytest_assert( + wait_until(30, 3, 0, is_route_in_fib, duthost, PL_PERMITTED_ROUTE_V4), + "Route {} does not match BLOCK_20_NET — should be installed in FIB".format( + PL_PERMITTED_ROUTE_V4) + ) + logger.info("PASS: {} permitted to FIB (no prefix-list match)".format(PL_PERMITTED_ROUTE_V4)) + finally: + update_routes("withdraw", exabgp_setup["ptf_ip"], exabgp_setup["exabgp_port"], route) + + +def test_table_map_ipv6_prefix_list_deny(duthosts, enum_dut_hostname, + setup_prefix_list_table_map, exabgp_setup): + """ + IPv6 route matching prefix-list BLOCK_TEST_V6 is blocked from FIB. + + Config (from Work Item 37441388): + ipv6 prefix-list BLOCK_TEST_V6 seq 10 permit 2a01:20::/32 ge 64 le 64 + route-map FIB_FILTER_PL_V6 deny 10 → match ipv6 address prefix-list BLOCK_TEST_V6 + route-map FIB_FILTER_PL_V6 permit 1000 + table-map FIB_FILTER_PL_V6 + + 2a01:20:0:1::/64 is within 2a01:20::/32 and exactly /64 → blocked from FIB. + """ + duthost = duthosts[enum_dut_hostname] + route = {"prefix": PL_BLOCKED_ROUTE_V6, "nexthop": exabgp_setup["nhipv6"]} + + try: + update_routes("announce", exabgp_setup["ptf_ip"], exabgp_setup["exabgp_port_v6"], route) + + pytest_assert( + wait_until(60, 3, 5, is_route_in_rib, duthost, PL_BLOCKED_ROUTE_V6, 6), + "IPv6 route {} not received in BGP RIB".format(PL_BLOCKED_ROUTE_V6) + ) + pytest_assert( + not is_route_in_fib(duthost, PL_BLOCKED_ROUTE_V6), + "IPv6 route {} matches BLOCK_TEST_V6 — should be blocked from FIB".format( + PL_BLOCKED_ROUTE_V6) + ) + logger.info("PASS: IPv6 {} blocked from FIB by prefix-list table-map".format( + PL_BLOCKED_ROUTE_V6)) + finally: + update_routes("withdraw", exabgp_setup["ptf_ip"], exabgp_setup["exabgp_port_v6"], route) diff --git a/tests/bgp/test_bgp_table_map_device_type.py b/tests/bgp/test_bgp_table_map_device_type.py new file mode 100644 index 00000000000..0eff3e7c391 --- /dev/null +++ b/tests/bgp/test_bgp_table_map_device_type.py @@ -0,0 +1,110 @@ +""" +Test BGP table-map device type gating (SELECTIVE_ROUTE_DOWNLOAD) + +Verifies that `table-map SELECTIVE_ROUTE_DOWNLOAD_V4/V6` is generated in the +FRR bgpd config only for device types that should have FIB filtering enabled: + - UpperSpineRouter → table-map PRESENT + - SpineRouter + UpstreamLC → table-map PRESENT + - SpineRouter + DownstreamLC → table-map ABSENT + - LeafRouter → table-map ABSENT + +These tests are kept separate from test_bgp_table_map.py to avoid interference +with the module-level device type fixture used in the FIB filtering tests. + +Related: Work Item 37441388 (SELECTIVE_ROUTE_DOWNLOAD) +""" + +import logging +import pytest + +from tests.common.helpers.assertions import pytest_assert +from tests.bgp.test_bgp_table_map import ( + set_device_type, + restart_bgp_and_wait, + restart_bgp_and_wait_responsive, +) + +pytestmark = [ + pytest.mark.topology('t1', 't2', 'lrh', 'urh'), + pytest.mark.skip_check_dut_health +] + +logger = logging.getLogger(__name__) + +TABLE_MAP_V4 = "table-map SELECTIVE_ROUTE_DOWNLOAD_V4" +TABLE_MAP_V6 = "table-map SELECTIVE_ROUTE_DOWNLOAD_V6" + + +def is_table_map_configured(duthost): + """ + Check if table-map SELECTIVE_ROUTE_DOWNLOAD_V4 is in the running FRR config. + Handles both unified (frr.conf) and split (bgpd.conf) routing config modes. + """ + routing_mode = duthost.shell( + "sonic-cfggen -d -v DEVICE_METADATA.localhost.docker_routing_config_mode", + module_ignore_errors=True + )["stdout"].strip() + conf_file = "/etc/frr/frr.conf" if routing_mode == "unified" else "/etc/frr/bgpd.conf" + output = duthost.shell( + "docker exec bgp cat {}".format(conf_file), module_ignore_errors=True + )["stdout"] + return TABLE_MAP_V4 in output, TABLE_MAP_V6 in output + + +@pytest.mark.parametrize("device_type,subtype,should_enable", [ + ("UpperSpineRouter", None, True), + ("SpineRouter", "UpstreamLC", True), + ("SpineRouter", "DownstreamLC", False), + ("LeafRouter", None, False), + ("UpperRegionalHub", None, True), +]) +def test_table_map_device_type_check(duthosts, enum_dut_hostname, + device_type, subtype, should_enable): + """ + Verify table-map is present/absent in bgpd config for each device type. + + Changes device type in DEVICE_METADATA, restarts BGP docker to regenerate + FRR templates, checks bgpd.conf for table-map directive, then restores. + """ + duthost = duthosts[enum_dut_hostname] + + original_type = duthost.shell( + "redis-cli -n 4 HGET 'DEVICE_METADATA|localhost' type", + module_ignore_errors=True + )["stdout"].strip() or "ToRRouter" + original_subtype = duthost.shell( + "redis-cli -n 4 HGET 'DEVICE_METADATA|localhost' subtype", + module_ignore_errors=True + )["stdout"].strip() or None + + try: + logger.info("Setting device type: {}/{}".format(device_type, subtype)) + set_device_type(duthost, device_type, subtype) + if should_enable: + restart_bgp_and_wait(duthost) + else: + # Device types where table-map is expected to be disabled (e.g. + # LeafRouter) may render a peer-group template incompatible with + # this topology's real neighbors, so full neighbor convergence + # isn't guaranteed here and isn't needed for this assertion - + # only bgpd/bgpcfgd being up and having processed CONFIG_DB matters. + restart_bgp_and_wait_responsive(duthost) + + v4_present, v6_present = is_table_map_configured(duthost) + + pytest_assert( + v4_present == should_enable, + "Device {}/{}: {} expected={}, actual={}".format( + device_type, subtype, TABLE_MAP_V4, should_enable, v4_present) + ) + pytest_assert( + v6_present == should_enable, + "Device {}/{}: {} expected={}, actual={}".format( + device_type, subtype, TABLE_MAP_V6, should_enable, v6_present) + ) + logger.info("PASS: {}/{} → table-map enabled={}".format(device_type, subtype, v4_present)) + finally: + logger.info("Restoring device type: {}/{}".format(original_type, original_subtype)) + set_device_type(duthost, original_type, original_subtype) + restart_bgp_and_wait(duthost) + logger.info("Device type restored") diff --git a/tests/bgp/test_urh_anchor_prefix.py b/tests/bgp/test_urh_anchor_prefix.py new file mode 100644 index 00000000000..b780a43142e --- /dev/null +++ b/tests/bgp/test_urh_anchor_prefix.py @@ -0,0 +1,826 @@ +""" +Test URH (UpperRegionalHub) ANCHOR_PREFIX end-to-end pipeline. + +Exercises the real production path: + + CONFIG_DB PREFIX_LIST|ANCHOR_PREFIX| + -> bgpcfgd PrefixListMgr (managers_prefix_list.py) + -> bgpd/radian/add_radian.conf.j2 / del_radian.conf.j2 + - ip[v6] prefix-list ANCHOR_CONTRIBUTING_ROUTES permit ge + - aggregate-address route-map TAG_ANCHOR_COMMUNITY + -> TAG_ANCHOR_COMMUNITY tags the resulting aggregate with + LOCAL_ANCHOR_ROUTE_COMMUNITY + -> SELECTIVE_ROUTE_DOWNLOAD_V4/V6 table-map suppresses that + community-tagged aggregate from FIB while contributing + (more-specific) routes stay installed normally. + +This is distinct from: + - tests/bgp/test_bgp_table_map.py + test_bgp_table_map_device_type.py: + generic table-map / device-type-gating mechanism tests (community + injected directly via ExaBGP, no CONFIG_DB PREFIX_LIST involvement). + - tests/bgp/test_prefix_list_suppress.py: PrefixListMgr refactor / + SUPPRESS_PREFIX regression, CLI-focused, no data-plane verification. + - tests/bgp/test_prefix_list.py: ANCHOR_PREFIX end-to-end on real T2/LRH/URH + chassis topologies with RH/AH neighbor role-based outbound signaling — + requires a SpineRouter+UpstreamLC/UpperSpineRouter DUT with RegionalHub/ + AZNGHub neighbors, and does not cover the UpperRegionalHub device type. + +None of the above exercise the UpperRegionalHub device type against the +real ANCHOR_PREFIX CONFIG_DB pipeline, which is what this file covers. + +On topologies where bgpd.conf is generated from bgpd.main.conf.j2 only +(e.g. this KVM t0 testbed, using bgpcfgd dynamic neighbor configuration +instead of the static minigraph-driven general/peer-group.conf.j2 +template), the outer TAG_ANCHOR_COMMUNITY / SELECTIVE_ROUTE_DOWNLOAD_V4/V6 +route-maps are not rendered by the FRR template (they require rwa/lowerrh +peer-group roles that a generic T1 neighbor topology doesn't have). In that +case we fall back to applying them directly via vtysh, mirroring what the +real templates would produce, so the CONFIG_DB -> PrefixListMgr -> +add_radian.conf.j2 plumbing (real bgpcfgd production code) is still +exercised end-to-end. This mirrors the same fallback pattern already used +in test_bgp_table_map.py's setup_table_map_device_type fixture. + +Assumes a single-ASIC DUT for the FRR-level checks, matching the existing +helpers in test_bgp_table_map.py. +""" + +import json +import logging +import re +import time + +import pytest + +from tests.common.config_reload import config_reload +from tests.common.helpers.assertions import pytest_assert, pytest_require +from tests.common.utilities import wait_until +from tests.bgp.bgp_helpers import update_routes +from tests.bgp.test_bgp_table_map import ( # noqa: F401 + set_device_type, + restart_bgp_and_wait, + restart_bgp_and_wait_responsive, + bgpcfgd_is_running, + get_anchor_community, + get_bgp_asn, + vtysh, + apply_table_map, + remove_table_map, + is_route_in_rib, + is_route_in_fib, + exabgp_setup, +) + +pytestmark = [ + pytest.mark.topology('t0', 't1', 't2', 'lrh', 'urh', 'any'), + pytest.mark.skip_check_dut_health +] + +logger = logging.getLogger(__name__) + + +@pytest.fixture(scope="module", autouse=True) +def skip_multi_asic(duthosts, enum_dut_hostname): + """Skip this module on multi-ASIC DUTs. + + This module assumes a single-ASIC DUT (matching test_bgp_table_map.py's + helpers, several of which are re-used here directly): FRR-level checks + hard-code the single 'bgp' docker/namespace rather than per-ASIC + 'bgpN'/'-n asicN' targeting, so on multi-ASIC platforms they'd target the + wrong or nonexistent container and fail before exercising the + ANCHOR_PREFIX pipeline at all. + """ + duthost = duthosts[enum_dut_hostname] + pytest_require( + not duthost.is_multi_asic, + "test_urh_anchor_prefix module requires a single-ASIC DUT " + "(helpers assume a single 'bgp' container)" + ) + + +PREFIX_TYPE = "ANCHOR_PREFIX" +ANCHOR_PL_NAME = "ANCHOR_CONTRIBUTING_ROUTES" + +# Single-anchor / multi-anchor test prefixes +ANCHOR_A = "205.168.0.0/24" +ANCHOR_A_LEN = 24 +CONTRIB_A1 = "205.168.0.64/26" +CONTRIB_A2 = "205.168.0.128/26" + +ANCHOR_B = "205.169.0.0/24" +ANCHOR_B_LEN = 24 +CONTRIB_B1 = "205.169.0.64/26" + +# Overlapping parent/child anchors for the partial-delete test +ANCHOR_PARENT = "205.160.0.0/16" +ANCHOR_PARENT_LEN = 16 +ANCHOR_CHILD = "205.160.5.0/24" +ANCHOR_CHILD_LEN = 24 +CONTRIB_PARENT = "205.160.10.0/26" +CONTRIB_CHILD = "205.160.5.64/26" + +ALL_TEST_ANCHOR_PREFIXES = [ANCHOR_A, ANCHOR_B, ANCHOR_PARENT, ANCHOR_CHILD] + +CONSTANTS_FILE = "/etc/sonic/constants.yml" + + +# ============================================================ +# Helpers +# ============================================================ + +def op_anchor_prefix(duthost, prefix, action, ignore_error=False): + """Run 'sudo prefix_list ANCHOR_PREFIX '.""" + pytest_assert(action in ("add", "remove"), "Invalid action {!r}".format(action)) + cmd = "sudo prefix_list {} {} {}".format(action, PREFIX_TYPE, prefix) + return duthost.shell(cmd, module_ignore_errors=ignore_error) + + +def write_anchor_prefix_directly(duthost, prefix): + """Bypass the CLI and write straight into CONFIG_DB (used by the negative test).""" + key = 'PREFIX_LIST|{}|{}'.format(PREFIX_TYPE, prefix) + duthost.shell('sonic-db-cli CONFIG_DB hset "{}" NULL NULL'.format(key)) + + +def delete_anchor_prefix_directly(duthost, prefix): + key = 'PREFIX_LIST|{}|{}'.format(PREFIX_TYPE, prefix) + duthost.shell('sonic-db-cli CONFIG_DB DEL "{}"'.format(key), module_ignore_errors=True) + + +def anchor_prefix_in_config_db(duthost, prefix): + """Return True if PREFIX_LIST|ANCHOR_PREFIX| exists in CONFIG_DB.""" + key = 'PREFIX_LIST|{}|{}'.format(PREFIX_TYPE, prefix) + out = duthost.shell('sonic-db-cli CONFIG_DB keys "{}"'.format(key), module_ignore_errors=True)["stdout"] + return key in out + + +def anchor_prefix_list_entry_count(duthost, prefix, prefixlen, ip_version=4): + """Count occurrences of 'permit ge ' in ANCHOR_CONTRIBUTING_ROUTES. + + Query only bgpd (-d bgpd) rather than broadcasting to all daemons: plain + 'vtysh -c' fans the command out to every daemon that recognizes it (here, + both zebra and bgpd keep their own copy of the prefix-list), and vtysh + prefixes each daemon's identical reply with its name, so a single real + entry shows up twice ("ZEBRA: ..." and "BGP: ...") and inflates the count. + """ + ipv = "ip" if ip_version == 4 else "ipv6" + out = duthost.shell( + "docker exec bgp vtysh -d bgpd -c 'show {} prefix-list {}'".format(ipv, ANCHOR_PL_NAME), + module_ignore_errors=True + )["stdout"] + pattern = r"permit\s+{}\s+ge\s+{}\b".format(re.escape(prefix), prefixlen + 1) + return len(re.findall(pattern, out)) + + +def anchor_prefix_list_entry_present(duthost, prefix, prefixlen, ip_version=4): + return anchor_prefix_list_entry_count(duthost, prefix, prefixlen, ip_version) > 0 + + +def aggregate_address_count(duthost, prefix): + """Count occurrences of 'aggregate-address route-map TAG_ANCHOR_COMMUNITY' in running-config.""" + out = duthost.shell("docker exec bgp vtysh -c 'show running-config'", module_ignore_errors=True)["stdout"] + needle = "aggregate-address {} route-map TAG_ANCHOR_COMMUNITY".format(prefix) + return out.count(needle) + + +def has_aggregate_address(duthost, prefix): + return aggregate_address_count(duthost, prefix) > 0 + + +def get_route_communities(duthost, prefix, ip_version=4): + """Return the list of BGP communities attached to prefix, or [] if not present/parseable.""" + ipv = "ipv4" if ip_version == 4 else "ipv6" + cmd = "docker exec bgp vtysh -c 'show bgp {} {} json'".format(ipv, prefix) + out = duthost.shell(cmd, module_ignore_errors=True)["stdout"] + try: + data = json.loads(out) + except (ValueError, TypeError): + return [] + communities = [] + for path in data.get("paths", []): + communities.extend(path.get("community", {}).get("list", [])) + return communities + + +def _has_anchor_route_maps(duthost): + """Return True if TAG_ANCHOR_COMMUNITY is already defined (rendered by the role-aware FRR template).""" + out = duthost.shell("docker exec bgp vtysh -c 'show running-config'", module_ignore_errors=True)["stdout"] + return "route-map TAG_ANCHOR_COMMUNITY" in out + + +def _apply_urh_anchor_route_maps_vtysh(duthost, community): + """ + Apply TAG_ANCHOR_COMMUNITY and SELECTIVE_ROUTE_DOWNLOAD_V4/V6 directly via vtysh. + + Mirrors what the general/peer-group.conf.j2 table-map and its supporting + route-maps would render on a static minigraph-driven URH topology. Used as a + fallback on topologies (like this KVM t0 testbed) where those templates + aren't rendered. + """ + vtysh(duthost, "bgp community-list standard LOCAL_ANCHOR_ROUTE_COMMUNITY permit {}".format(community)) + vtysh(duthost, "route-map TAG_ANCHOR_COMMUNITY permit 10", + "set community {} additive".format(community)) + vtysh(duthost, "route-map SELECTIVE_ROUTE_DOWNLOAD_V4 deny 10", + "match community LOCAL_ANCHOR_ROUTE_COMMUNITY") + vtysh(duthost, "route-map SELECTIVE_ROUTE_DOWNLOAD_V4 permit 1000") + vtysh(duthost, "route-map SELECTIVE_ROUTE_DOWNLOAD_V6 deny 10", + "match community LOCAL_ANCHOR_ROUTE_COMMUNITY") + vtysh(duthost, "route-map SELECTIVE_ROUTE_DOWNLOAD_V6 permit 1000") + asn = get_bgp_asn(duthost) + apply_table_map(duthost, asn, "SELECTIVE_ROUTE_DOWNLOAD_V4", ip_version=4) + apply_table_map(duthost, asn, "SELECTIVE_ROUTE_DOWNLOAD_V6", ip_version=6) + logger.info("Applied TAG_ANCHOR_COMMUNITY/SELECTIVE_ROUTE_DOWNLOAD_V4/V6 via vtysh (template fallback)") + + +def _remove_urh_anchor_route_maps_vtysh(duthost): + asn = get_bgp_asn(duthost) + remove_table_map(duthost, asn, "SELECTIVE_ROUTE_DOWNLOAD_V4", ip_version=4) + remove_table_map(duthost, asn, "SELECTIVE_ROUTE_DOWNLOAD_V6", ip_version=6) + vtysh(duthost, "no route-map SELECTIVE_ROUTE_DOWNLOAD_V4") + vtysh(duthost, "no route-map SELECTIVE_ROUTE_DOWNLOAD_V6") + vtysh(duthost, "no route-map TAG_ANCHOR_COMMUNITY") + vtysh(duthost, "no bgp community-list standard LOCAL_ANCHOR_ROUTE_COMMUNITY") + + +def _cleanup_all_anchor_prefixes(duthost): + for prefix in ALL_TEST_ANCHOR_PREFIXES: + op_anchor_prefix(duthost, prefix, "remove", ignore_error=True) + + +# ============================================================ +# Module-level fixture: device type + anchor route-maps +# ============================================================ + +@pytest.fixture(scope="module") +def setup_urh_anchor(duthosts, enum_dut_hostname): + """ + Set device type to UpperRegionalHub and ensure TAG_ANCHOR_COMMUNITY / + SELECTIVE_ROUTE_DOWNLOAD_V4/V6 are in place (via FRR template or vtysh + fallback), so the ANCHOR_PREFIX CONFIG_DB pipeline can be exercised. + + Yields (duthost, local_anchor_route_community). + """ + duthost = duthosts[enum_dut_hostname] + + original_type = duthost.shell( + "redis-cli -n 4 HGET 'DEVICE_METADATA|localhost' type", + module_ignore_errors=True + )["stdout"].strip() or "ToRRouter" + original_subtype = duthost.shell( + "redis-cli -n 4 HGET 'DEVICE_METADATA|localhost' subtype", + module_ignore_errors=True + )["stdout"].strip() or None + + used_vtysh_fallback = False + try: + logger.info("Setting device type to UpperRegionalHub for anchor-prefix pipeline tests") + set_device_type(duthost, "UpperRegionalHub") + restart_bgp_and_wait(duthost) + + community = get_anchor_community(duthost) + + if _has_anchor_route_maps(duthost): + logger.info("TAG_ANCHOR_COMMUNITY/SELECTIVE_ROUTE_DOWNLOAD rendered by FRR template") + else: + logger.info("Role-aware anchor route-maps not in bgpd.conf (bgpcfgd/KVM topology) — " + "applying via vtysh") + _apply_urh_anchor_route_maps_vtysh(duthost, community) + used_vtysh_fallback = True + + yield duthost, community + finally: + _cleanup_all_anchor_prefixes(duthost) + if used_vtysh_fallback: + _remove_urh_anchor_route_maps_vtysh(duthost) + logger.info("Restoring device type to {}/{}".format(original_type, original_subtype)) + set_device_type(duthost, original_type, original_subtype) + restart_bgp_and_wait(duthost) + # Some tests in this module (config-reload persistence) may have run + # 'config save'. Persist the restored device type so the testbed's + # on-disk config_db.json doesn't end up stuck as UpperRegionalHub. + duthost.shell("sudo config save -y", module_ignore_errors=True) + logger.info("Device type restored") + + +# ============================================================ +# Tests +# ============================================================ + +def test_urh_anchor_single_prefix(setup_urh_anchor, exabgp_setup): # noqa F811 + """ + urh-tc-single-anchor + + Configuring a single ANCHOR_PREFIX produces: + - CONFIG_DB PREFIX_LIST|ANCHOR_PREFIX| entry + - ip prefix-list ANCHOR_CONTRIBUTING_ROUTES permit ge + - aggregate-address route-map TAG_ANCHOR_COMMUNITY + Once a contributing (more-specific) route is announced, the aggregate: + - appears in the BGP table tagged with LOCAL_ANCHOR_ROUTE_COMMUNITY + - is suppressed from FIB by the table-map + while the contributing route itself is installed in FIB normally. + """ + duthost, community = setup_urh_anchor + route = {"prefix": CONTRIB_A1, "nexthop": exabgp_setup["nhipv4"]} + + try: + op_anchor_prefix(duthost, ANCHOR_A, "add") + pytest_assert( + wait_until(15, 3, 0, anchor_prefix_in_config_db, duthost, ANCHOR_A), + "CONFIG_DB entry for {} not created".format(ANCHOR_A) + ) + pytest_assert( + wait_until(30, 3, 0, anchor_prefix_list_entry_present, duthost, ANCHOR_A, ANCHOR_A_LEN), + "ANCHOR_CONTRIBUTING_ROUTES prefix-list entry for {} not rendered".format(ANCHOR_A) + ) + pytest_assert( + wait_until(30, 3, 0, has_aggregate_address, duthost, ANCHOR_A), + "aggregate-address for {} not configured".format(ANCHOR_A) + ) + + update_routes("announce", exabgp_setup["ptf_ip"], exabgp_setup["exabgp_port"], route) + pytest_assert( + wait_until(60, 3, 5, is_route_in_rib, duthost, CONTRIB_A1, 4), + "Contributing route {} not received in BGP RIB".format(CONTRIB_A1) + ) + pytest_assert( + wait_until(30, 3, 5, is_route_in_rib, duthost, ANCHOR_A, 4), + "Anchor aggregate {} did not appear in BGP table".format(ANCHOR_A) + ) + + communities = get_route_communities(duthost, ANCHOR_A) + pytest_assert( + community in communities, + "Aggregate {} missing anchor community {}: got {}".format(ANCHOR_A, community, communities) + ) + pytest_assert( + not is_route_in_fib(duthost, ANCHOR_A), + "Anchor aggregate {} should be suppressed from FIB by table-map".format(ANCHOR_A) + ) + pytest_assert( + wait_until(30, 3, 0, is_route_in_fib, duthost, CONTRIB_A1), + "Contributing route {} should be installed in FIB".format(CONTRIB_A1) + ) + logger.info("PASS: single anchor prefix {} suppressed, contributing route {} installed" + .format(ANCHOR_A, CONTRIB_A1)) + finally: + update_routes("withdraw", exabgp_setup["ptf_ip"], exabgp_setup["exabgp_port"], route) + op_anchor_prefix(duthost, ANCHOR_A, "remove", ignore_error=True) + + +def test_urh_anchor_multiple_prefixes(setup_urh_anchor, exabgp_setup): # noqa F811 + """ + urh-tc-multi-anchor + + Multiple ANCHOR_PREFIX entries configured simultaneously are each + independently rendered into the prefix-list/aggregate-address config, + and each resulting aggregate is independently suppressed from FIB while + its own contributing routes are unaffected. + """ + duthost, community = setup_urh_anchor + routes = [ + {"prefix": CONTRIB_A1, "nexthop": exabgp_setup["nhipv4"]}, + {"prefix": CONTRIB_B1, "nexthop": exabgp_setup["nhipv4"]}, + ] + + try: + op_anchor_prefix(duthost, ANCHOR_A, "add") + op_anchor_prefix(duthost, ANCHOR_B, "add") + + for prefix, prefixlen in ((ANCHOR_A, ANCHOR_A_LEN), (ANCHOR_B, ANCHOR_B_LEN)): + pytest_assert( + wait_until(15, 3, 0, anchor_prefix_in_config_db, duthost, prefix), + "CONFIG_DB entry for {} not created".format(prefix) + ) + pytest_assert( + wait_until(30, 3, 0, anchor_prefix_list_entry_present, duthost, prefix, prefixlen), + "ANCHOR_CONTRIBUTING_ROUTES entry for {} not rendered".format(prefix) + ) + pytest_assert( + wait_until(30, 3, 0, has_aggregate_address, duthost, prefix), + "aggregate-address for {} not configured".format(prefix) + ) + + for route in routes: + update_routes("announce", exabgp_setup["ptf_ip"], exabgp_setup["exabgp_port"], route) + + for prefix in (CONTRIB_A1, CONTRIB_B1, ANCHOR_A, ANCHOR_B): + pytest_assert( + wait_until(60, 3, 5, is_route_in_rib, duthost, prefix, 4), + "{} not received in BGP RIB".format(prefix) + ) + + for prefix, contrib in ((ANCHOR_A, CONTRIB_A1), (ANCHOR_B, CONTRIB_B1)): + communities = get_route_communities(duthost, prefix) + pytest_assert( + community in communities, + "Aggregate {} missing anchor community {}: got {}".format(prefix, community, communities) + ) + pytest_assert( + not is_route_in_fib(duthost, prefix), + "Anchor aggregate {} should be suppressed from FIB".format(prefix) + ) + pytest_assert( + wait_until(30, 3, 0, is_route_in_fib, duthost, contrib), + "Contributing route {} should be installed in FIB".format(contrib) + ) + logger.info("PASS: both anchors {}/{} independently suppressed".format(ANCHOR_A, ANCHOR_B)) + finally: + for route in routes: + update_routes("withdraw", exabgp_setup["ptf_ip"], exabgp_setup["exabgp_port"], route) + op_anchor_prefix(duthost, ANCHOR_A, "remove", ignore_error=True) + op_anchor_prefix(duthost, ANCHOR_B, "remove", ignore_error=True) + + +@pytest.mark.xfail( + strict=False, + reason="Known issue: bgpd aggregate-address recomputation on withdrawal is " + "unreliable on this topology (root-caused, tracked separately - see " + "urh-tc-partial-delete-overlap in PR description)." +) +def test_urh_anchor_partial_delete_overlap(setup_urh_anchor, exabgp_setup): # noqa F811 + """ + urh-tc-partial-delete-overlap + + A child anchor prefix (205.160.5.0/24) nested inside a parent anchor + prefix (205.160.0.0/16) is configured alongside it. Deleting the child + only removes the child's own prefix-list entry / aggregate-address / + aggregate route, leaving the parent's config and aggregate route intact. + """ + duthost, community = setup_urh_anchor + route_parent = {"prefix": CONTRIB_PARENT, "nexthop": exabgp_setup["nhipv4"]} + route_child = {"prefix": CONTRIB_CHILD, "nexthop": exabgp_setup["nhipv4"]} + + try: + op_anchor_prefix(duthost, ANCHOR_PARENT, "add") + op_anchor_prefix(duthost, ANCHOR_CHILD, "add") + for prefix, prefixlen in ((ANCHOR_PARENT, ANCHOR_PARENT_LEN), (ANCHOR_CHILD, ANCHOR_CHILD_LEN)): + pytest_assert( + wait_until(15, 3, 0, anchor_prefix_in_config_db, duthost, prefix), + "CONFIG_DB entry for {} not created".format(prefix) + ) + pytest_assert( + wait_until(30, 3, 0, anchor_prefix_list_entry_present, duthost, prefix, prefixlen), + "ANCHOR_CONTRIBUTING_ROUTES entry for {} not rendered".format(prefix) + ) + pytest_assert( + wait_until(30, 3, 0, has_aggregate_address, duthost, prefix), + "aggregate-address for {} not configured".format(prefix) + ) + + # Precondition: with no contributing routes announced yet, neither + # aggregate may be in the RIB. Without this check, the "aggregate + # appeared" assertions below would be an unverified assumption - an + # aggregate could already be present (stale/leftover) rather than + # caused by the announces that follow. + for prefix in (ANCHOR_PARENT, ANCHOR_CHILD): + pytest_assert( + not is_route_in_rib(duthost, prefix, 4), + "Anchor aggregate {} should not be in RIB before any contributing route is announced" + .format(prefix) + ) + + update_routes("announce", exabgp_setup["ptf_ip"], exabgp_setup["exabgp_port"], route_parent) + update_routes("announce", exabgp_setup["ptf_ip"], exabgp_setup["exabgp_port"], route_child) + for prefix in (CONTRIB_PARENT, CONTRIB_CHILD, ANCHOR_PARENT, ANCHOR_CHILD): + pytest_assert( + wait_until(60, 3, 5, is_route_in_rib, duthost, prefix, 4), + "{} not received in BGP RIB".format(prefix) + ) + for prefix in (ANCHOR_PARENT, ANCHOR_CHILD): + pytest_assert( + not is_route_in_fib(duthost, prefix), + "Aggregate {} should be suppressed from FIB before deletion".format(prefix) + ) + + # Delete only the child anchor + op_anchor_prefix(duthost, ANCHOR_CHILD, "remove") + pytest_assert( + wait_until(15, 3, 0, lambda: not anchor_prefix_in_config_db(duthost, ANCHOR_CHILD)), + "CONFIG_DB entry for child {} should be removed".format(ANCHOR_CHILD) + ) + pytest_assert( + wait_until(30, 3, 0, lambda: not anchor_prefix_list_entry_present( + duthost, ANCHOR_CHILD, ANCHOR_CHILD_LEN)), + "ANCHOR_CONTRIBUTING_ROUTES entry for child {} should be removed".format(ANCHOR_CHILD) + ) + pytest_assert( + wait_until(30, 3, 0, lambda: not has_aggregate_address(duthost, ANCHOR_CHILD)), + "aggregate-address for child {} should be removed".format(ANCHOR_CHILD) + ) + pytest_assert( + # 30s was tuned against a lighter synthetic setup; on this KVM host + # under a real ~100k-entry full BGP table (6 confederation + # neighbors), aggregate withdrawal genuinely takes longer to + # propagate under sustained CPU/memory pressure, so use a larger + # budget to avoid flaking on slow-but-healthy convergence. + wait_until(90, 5, 10, lambda: not is_route_in_rib(duthost, ANCHOR_CHILD, 4)), + "Child aggregate {} should disappear from BGP table after deletion".format(ANCHOR_CHILD) + ) + + # Parent anchor must remain fully intact and unaffected + pytest_assert( + anchor_prefix_in_config_db(duthost, ANCHOR_PARENT), + "CONFIG_DB entry for parent {} should remain after child deletion".format(ANCHOR_PARENT) + ) + pytest_assert( + anchor_prefix_list_entry_present(duthost, ANCHOR_PARENT, ANCHOR_PARENT_LEN), + "ANCHOR_CONTRIBUTING_ROUTES entry for parent {} should remain".format(ANCHOR_PARENT) + ) + pytest_assert( + has_aggregate_address(duthost, ANCHOR_PARENT), + "aggregate-address for parent {} should remain".format(ANCHOR_PARENT) + ) + pytest_assert( + is_route_in_rib(duthost, ANCHOR_PARENT, 4), + "Parent aggregate {} should still be in BGP table".format(ANCHOR_PARENT) + ) + pytest_assert( + not is_route_in_fib(duthost, ANCHOR_PARENT), + "Parent aggregate {} should still be suppressed from FIB".format(ANCHOR_PARENT) + ) + logger.info("PASS: deleting child anchor {} left parent anchor {} intact" + .format(ANCHOR_CHILD, ANCHOR_PARENT)) + finally: + update_routes("withdraw", exabgp_setup["ptf_ip"], exabgp_setup["exabgp_port"], route_parent) + update_routes("withdraw", exabgp_setup["ptf_ip"], exabgp_setup["exabgp_port"], route_child) + op_anchor_prefix(duthost, ANCHOR_PARENT, "remove", ignore_error=True) + op_anchor_prefix(duthost, ANCHOR_CHILD, "remove", ignore_error=True) + + +@pytest.mark.xfail( + strict=False, + reason="Known issue: bgpd aggregate-address recomputation on withdrawal is " + "unreliable on this topology (root-caused, tracked separately - see " + "urh-tc-contributing-route-withdraw in PR description)." +) +def test_urh_anchor_contributing_route_withdraw(setup_urh_anchor, exabgp_setup): # noqa F811 + """ + urh-tc-contributing-route-withdraw + + Withdrawing the last contributing (more-specific) route under an anchor + aggregate causes FRR to stop originating the aggregate (standard + aggregate-address behavior: it only exists while at least one matching + more-specific route is present), even though the ANCHOR_PREFIX CONFIG_DB + entry and its rendered prefix-list/aggregate-address config remain + untouched. Re-announcing the contributing route must bring the aggregate + back with correct community tagging and FIB suppression. + """ + duthost, community = setup_urh_anchor + route = {"prefix": CONTRIB_A1, "nexthop": exabgp_setup["nhipv4"]} + + try: + op_anchor_prefix(duthost, ANCHOR_A, "add") + pytest_assert( + wait_until(30, 3, 0, has_aggregate_address, duthost, ANCHOR_A), + "aggregate-address for {} not configured".format(ANCHOR_A) + ) + + # Precondition: with no contributing route announced yet, the aggregate + # must NOT be in the RIB. Without this check, a later "aggregate appeared" + # assertion would be an unverified assumption - the aggregate could + # already have been present (stale/leftover) rather than caused by the + # announce below. + pytest_assert( + not is_route_in_rib(duthost, ANCHOR_A, 4), + "Anchor aggregate {} should not be in RIB before any contributing route is announced" + .format(ANCHOR_A) + ) + + update_routes("announce", exabgp_setup["ptf_ip"], exabgp_setup["exabgp_port"], route) + pytest_assert( + wait_until(60, 3, 5, is_route_in_rib, duthost, ANCHOR_A, 4), + "Anchor aggregate {} did not appear after contributing route announced".format(ANCHOR_A) + ) + + # Withdraw the only contributing route + update_routes("withdraw", exabgp_setup["ptf_ip"], exabgp_setup["exabgp_port"], route) + pytest_assert( + # 60s was tuned against a lighter synthetic setup; see the similar + # bump in test_urh_anchor_partial_delete_overlap above for why a + # heavier real full-table environment needs more headroom here. + wait_until(120, 5, 10, lambda: not is_route_in_rib(duthost, ANCHOR_A, 4)), + "Anchor aggregate {} should disappear once its last contributing route is withdrawn" + .format(ANCHOR_A) + ) + + # Config-side artifacts must remain untouched + pytest_assert( + anchor_prefix_in_config_db(duthost, ANCHOR_A), + "CONFIG_DB entry for {} should persist even though the aggregate withdrew".format(ANCHOR_A) + ) + pytest_assert( + anchor_prefix_list_entry_present(duthost, ANCHOR_A, ANCHOR_A_LEN), + "ANCHOR_CONTRIBUTING_ROUTES entry for {} should persist".format(ANCHOR_A) + ) + pytest_assert( + has_aggregate_address(duthost, ANCHOR_A), + "aggregate-address config for {} should persist".format(ANCHOR_A) + ) + + # Re-announcing the contributing route must bring the aggregate back + update_routes("announce", exabgp_setup["ptf_ip"], exabgp_setup["exabgp_port"], route) + pytest_assert( + wait_until(60, 3, 5, is_route_in_rib, duthost, ANCHOR_A, 4), + "Anchor aggregate {} did not reappear after contributing route re-announced".format(ANCHOR_A) + ) + communities = get_route_communities(duthost, ANCHOR_A) + pytest_assert( + community in communities, + "Reappeared aggregate {} missing anchor community: {}".format(ANCHOR_A, communities) + ) + pytest_assert( + not is_route_in_fib(duthost, ANCHOR_A), + "Reappeared aggregate {} should still be suppressed from FIB".format(ANCHOR_A) + ) + logger.info("PASS: aggregate {} correctly withdrew/reformed with its contributing route" + .format(ANCHOR_A)) + finally: + update_routes("withdraw", exabgp_setup["ptf_ip"], exabgp_setup["exabgp_port"], route) + op_anchor_prefix(duthost, ANCHOR_A, "remove", ignore_error=True) + + +def test_urh_anchor_churn_idempotency(setup_urh_anchor): + """ + urh-tc-churn-idempotency + + Rapidly adding/removing the same ANCHOR_PREFIX entry must converge + idempotently: no duplicate ANCHOR_CONTRIBUTING_ROUTES prefix-list entries + or duplicate aggregate-address lines accumulate, and bgpcfgd does not + crash during the churn. + """ + duthost, _ = setup_urh_anchor + + try: + for i in range(5): + op_anchor_prefix(duthost, ANCHOR_A, "add") + op_anchor_prefix(duthost, ANCHOR_A, "remove") + + pytest_assert(bgpcfgd_is_running(duthost), "bgpcfgd should still be running after churn") + + # Final add — verify convergence to exactly one of each artifact + op_anchor_prefix(duthost, ANCHOR_A, "add") + pytest_assert( + wait_until(15, 3, 0, anchor_prefix_in_config_db, duthost, ANCHOR_A), + "CONFIG_DB entry for {} not created after churn".format(ANCHOR_A) + ) + pytest_assert( + wait_until(30, 3, 0, lambda: anchor_prefix_list_entry_count( + duthost, ANCHOR_A, ANCHOR_A_LEN) == 1), + "ANCHOR_CONTRIBUTING_ROUTES should have exactly one entry for {} after churn, got {}" + .format(ANCHOR_A, anchor_prefix_list_entry_count(duthost, ANCHOR_A, ANCHOR_A_LEN)) + ) + pytest_assert( + wait_until(30, 3, 0, lambda: aggregate_address_count(duthost, ANCHOR_A) == 1), + "aggregate-address for {} should appear exactly once after churn, got {}" + .format(ANCHOR_A, aggregate_address_count(duthost, ANCHOR_A)) + ) + pytest_assert(bgpcfgd_is_running(duthost), "bgpcfgd should still be running after final add") + logger.info("PASS: churn on {} converged idempotently, bgpcfgd stayed up".format(ANCHOR_A)) + finally: + op_anchor_prefix(duthost, ANCHOR_A, "remove", ignore_error=True) + + +def test_urh_anchor_config_reload_persistence(setup_urh_anchor, exabgp_setup): # noqa F811 + """ + urh-tc-reboot-persistence + + ANCHOR_PREFIX configuration must survive a 'config save' + 'config reload' + cycle (the standard sonic-mgmt proxy for reboot persistence, matching + TC-A4 in docs/testplan/PrefixListMgr-Refactor-Test-Plan.md): the CONFIG_DB + entry, and the FRR prefix-list/aggregate-address config bgpcfgd derives + from it, must be automatically regenerated on startup purely from + CONFIG_DB — no CLI re-invocation required. + """ + duthost, community = setup_urh_anchor + route = {"prefix": CONTRIB_A1, "nexthop": exabgp_setup["nhipv4"]} + + try: + op_anchor_prefix(duthost, ANCHOR_A, "add") + pytest_assert( + wait_until(30, 3, 0, has_aggregate_address, duthost, ANCHOR_A), + "aggregate-address for {} not configured before reload".format(ANCHOR_A) + ) + + duthost.shell("sudo config save -y") + # config_reload already restarts bgp as part of reapplying the full + # config; wait_for_bgp=True asks it to also wait for BGP sessions to + # re-establish, so a separate restart_bgp_and_wait() call here would + # only be a redundant extra 'docker restart bgp' (and an unnecessary + # hit against bgp.service's systemd restart-counter). + config_reload(duthost, wait=300, wait_for_bgp=True) + + # Core assertion: CONFIG_DB entry and derived FRR config persisted + # purely via config_db.json + bgpcfgd startup, no CLI re-run. + pytest_assert( + wait_until(30, 3, 0, anchor_prefix_in_config_db, duthost, ANCHOR_A), + "CONFIG_DB entry for {} did not survive config reload".format(ANCHOR_A) + ) + pytest_assert( + wait_until(60, 5, 5, anchor_prefix_list_entry_present, duthost, ANCHOR_A, ANCHOR_A_LEN), + "ANCHOR_CONTRIBUTING_ROUTES entry for {} not regenerated after reload".format(ANCHOR_A) + ) + pytest_assert( + wait_until(60, 5, 0, has_aggregate_address, duthost, ANCHOR_A), + "aggregate-address for {} not regenerated after reload".format(ANCHOR_A) + ) + + # The role-aware TAG_ANCHOR_COMMUNITY/SELECTIVE_ROUTE_DOWNLOAD route-maps are + # vtysh-runtime-only (not CONFIG_DB-backed) on this KVM topology, so a + # config reload wipes them — reapply the fallback if needed, then verify + # the full suppression pipeline still works end-to-end post-reload. + if not _has_anchor_route_maps(duthost): + _apply_urh_anchor_route_maps_vtysh(duthost, community) + + update_routes("announce", exabgp_setup["ptf_ip"], exabgp_setup["exabgp_port"], route) + pytest_assert( + wait_until(60, 3, 5, is_route_in_rib, duthost, ANCHOR_A, 4), + "Anchor aggregate {} did not reform after reload + contributing route".format(ANCHOR_A) + ) + communities = get_route_communities(duthost, ANCHOR_A) + pytest_assert( + community in communities, + "Aggregate {} missing anchor community after reload: {}".format(ANCHOR_A, communities) + ) + pytest_assert( + not is_route_in_fib(duthost, ANCHOR_A), + "Anchor aggregate {} should be suppressed from FIB after reload".format(ANCHOR_A) + ) + logger.info("PASS: ANCHOR_PREFIX {} persisted across config reload".format(ANCHOR_A)) + finally: + update_routes("withdraw", exabgp_setup["ptf_ip"], exabgp_setup["exabgp_port"], route) + op_anchor_prefix(duthost, ANCHOR_A, "remove", ignore_error=True) + + +def test_urh_anchor_device_gate_negative(duthosts, enum_dut_hostname): + """ + urh-tc-device-gate-negative + + On a device type NOT in PrefixListMgr's ANCHOR_PREFIX allowed_devices list + (e.g. LeafRouter), ANCHOR_PREFIX configuration must not produce any + prefix-list/aggregate-address rendering: + - the 'prefix_list' CLI itself rejects the add (client-side allow-list) + - even bypassing the CLI with a direct CONFIG_DB write does not cause + bgpcfgd's PrefixListMgr to render the aggregate-address / prefix-list + (server-side allow-list in managers_prefix_list.py) + + This test manages device type independently (not via setup_urh_anchor) + so it is not order-dependent on the other tests in this module. + """ + duthost = duthosts[enum_dut_hostname] + disallowed_type = "LeafRouter" + prefix = ANCHOR_A + + original_type = duthost.shell( + "redis-cli -n 4 HGET 'DEVICE_METADATA|localhost' type", + module_ignore_errors=True + )["stdout"].strip() or "ToRRouter" + original_subtype = duthost.shell( + "redis-cli -n 4 HGET 'DEVICE_METADATA|localhost' subtype", + module_ignore_errors=True + )["stdout"].strip() or None + + try: + set_device_type(duthost, disallowed_type) + # Use the lightweight check here, not restart_bgp_and_wait(): LeafRouter + # renders a peer-group template incompatible with this confederation + # topology's real neighbors (drops their required fast timers), so + # sessions never re-establish under this device type on this testbed. + # That's expected and irrelevant to what this test actually verifies - + # only bgpd/bgpcfgd being up matters for the assertions below. + restart_bgp_and_wait_responsive(duthost) + + # 1) CLI itself should reject the operation client-side. + result = op_anchor_prefix(duthost, prefix, "add", ignore_error=True) + pytest_assert( + result["rc"] != 0, + "prefix_list CLI should reject ANCHOR_PREFIX add on disallowed device type {}" + .format(disallowed_type) + ) + pytest_assert( + not anchor_prefix_in_config_db(duthost, prefix), + "CONFIG_DB should not contain {} after a CLI-rejected add".format(prefix) + ) + + # 2) Bypass the CLI: bgpcfgd's own PrefixListMgr gating must also reject it. + write_anchor_prefix_directly(duthost, prefix) + pytest_assert( + wait_until(15, 3, 0, anchor_prefix_in_config_db, duthost, prefix), + "Direct CONFIG_DB write for {} unexpectedly failed".format(prefix) + ) + time.sleep(15) # give bgpcfgd a chance to process the (rejected) key + pytest_assert( + not has_aggregate_address(duthost, prefix), + "aggregate-address for {} should NOT be rendered on disallowed device type {}" + .format(prefix, disallowed_type) + ) + pytest_assert( + not anchor_prefix_list_entry_present(duthost, prefix, 24), + "ANCHOR_CONTRIBUTING_ROUTES entry for {} should NOT be rendered on disallowed device type {}" + .format(prefix, disallowed_type) + ) + pytest_assert(bgpcfgd_is_running(duthost), "bgpcfgd should not crash on a rejected device type") + logger.info("PASS: ANCHOR_PREFIX correctly rejected on disallowed device type {}" + .format(disallowed_type)) + finally: + delete_anchor_prefix_directly(duthost, prefix) + op_anchor_prefix(duthost, prefix, "remove", ignore_error=True) + set_device_type(duthost, original_type, original_subtype) + restart_bgp_and_wait(duthost)