|
| 1 | +"""Check how fast FRR or QUAGGA will send updates to neighbors.""" |
| 2 | +import contextlib |
| 3 | +import ipaddress |
| 4 | +import logging |
| 5 | +import pytest |
| 6 | +import requests |
| 7 | +import tempfile |
| 8 | +import time |
| 9 | + |
| 10 | +from scapy.all import sniff, IP |
| 11 | +from scapy.contrib import bgp |
| 12 | +from tests.common.utilities import wait_tcp_connection |
| 13 | + |
| 14 | + |
| 15 | +pytestmark = [ |
| 16 | + pytest.mark.topology("any"), |
| 17 | +] |
| 18 | + |
| 19 | +BGP_SAVE_DEST_TMPL = "/tmp/bgp_%s.j2" |
| 20 | +NEIGHBOR_SAVE_DEST_TMPL = "/tmp/neighbor_%s.j2" |
| 21 | +BGP_LOG_TMPL = "/tmp/bgp%d.pcap" |
| 22 | +ANNOUNCED_SUBNETS = [ |
| 23 | + "10.10.100.0/27", |
| 24 | + "10.10.100.32/27", |
| 25 | + "10.10.100.64/27", |
| 26 | + "10.10.100.96/27", |
| 27 | + "10.10.100.128/27" |
| 28 | +] |
| 29 | +NEIGHBOR_ASN0 = 61000 |
| 30 | +NEIGHBOR_ASN1 = 61001 |
| 31 | +NEIGHBOR_PORT0 = 11000 |
| 32 | +NEIGHBOR_PORT1 = 11001 |
| 33 | + |
| 34 | + |
| 35 | +def _write_variable_from_j2_to_configdb(duthost, template_file, **kwargs): |
| 36 | + save_dest_path = kwargs.pop("save_dest_path", "/tmp/temp.j2") |
| 37 | + keep_dest_file = kwargs.pop("keep_dest_file", False) |
| 38 | + duthost.host.options["variable_manager"].extra_vars.update(kwargs) |
| 39 | + duthost.template(src=template_file, dest=save_dest_path) |
| 40 | + duthost.shell("sonic-cfggen -j %s --write-to-db" % save_dest_path) |
| 41 | + if not keep_dest_file: |
| 42 | + duthost.file(path=save_dest_path, state="absent") |
| 43 | + |
| 44 | + |
| 45 | +class BGPNeighbor(object): |
| 46 | + |
| 47 | + def __init__(self, duthost, ptfhost, name, iface, |
| 48 | + neighbor_ip, neighbor_asn, |
| 49 | + dut_ip, dut_asn, port, is_quagga=False): |
| 50 | + self.duthost = duthost |
| 51 | + self.ptfhost = ptfhost |
| 52 | + self.ptfip = ptfhost.host.options["inventory_manager"].get_host( |
| 53 | + ptfhost.hostname).vars["ansible_host"] |
| 54 | + self.iface = iface |
| 55 | + self.name = name |
| 56 | + self.ip = neighbor_ip |
| 57 | + self.asn = neighbor_asn |
| 58 | + self.peer_ip = dut_ip |
| 59 | + self.peer_asn = dut_asn |
| 60 | + self.port = port |
| 61 | + self.is_quagga = is_quagga |
| 62 | + |
| 63 | + def start_session(self): |
| 64 | + """Start the BGP session.""" |
| 65 | + logging.debug("start bgp session %s", self.name) |
| 66 | + self.ptfhost.shell("ifconfig %s %s/32" % (self.iface, self.ip)) |
| 67 | + self.ptfhost.exabgp( |
| 68 | + name=self.name, |
| 69 | + state="started", |
| 70 | + local_ip=self.ip, |
| 71 | + router_id=self.ip, |
| 72 | + peer_ip=self.peer_ip, |
| 73 | + local_asn=self.asn, |
| 74 | + peer_asn=self.peer_asn, |
| 75 | + port=self.port |
| 76 | + ) |
| 77 | + if not wait_tcp_connection(self.ptfhost, self.ptfip, self.port): |
| 78 | + raise RuntimeError("Failed to start BGP neighbor %s" % self.name) |
| 79 | + |
| 80 | + _write_variable_from_j2_to_configdb( |
| 81 | + self.duthost, |
| 82 | + "bgp/templates/neighbor_metadata_template.j2", |
| 83 | + save_dest_path=NEIGHBOR_SAVE_DEST_TMPL % self.name, |
| 84 | + neighbor_name=self.name, |
| 85 | + neighbor_lo_addr=self.ip, |
| 86 | + neighbor_mgmt_addr=self.ip, |
| 87 | + neighbor_hwsku=None, |
| 88 | + neighbor_type="ToRRouter" |
| 89 | + ) |
| 90 | + |
| 91 | + _write_variable_from_j2_to_configdb( |
| 92 | + self.duthost, |
| 93 | + "bgp/templates/bgp_template.j2", |
| 94 | + save_dest_path=BGP_SAVE_DEST_TMPL % self.name, |
| 95 | + db_table_name="BGP_NEIGHBOR", |
| 96 | + peer_addr=self.ip, |
| 97 | + asn=self.asn, |
| 98 | + local_addr=self.peer_ip, |
| 99 | + peer_name=self.name |
| 100 | + ) |
| 101 | + |
| 102 | + if self.is_quagga: |
| 103 | + allow_ebgp_multihop_cmd = ( |
| 104 | + "vtysh " |
| 105 | + "-c 'configure terminal' " |
| 106 | + "-c 'router bgp %s' " |
| 107 | + "-c 'neighbor %s ebgp-multihop'" |
| 108 | + ) |
| 109 | + allow_ebgp_multihop_cmd %= (self.peer_asn, self.ip) |
| 110 | + self.duthost.shell(allow_ebgp_multihop_cmd) |
| 111 | + |
| 112 | + # populate DUT arp table |
| 113 | + self.duthost.shell("ping -c 3 %s" % (self.ip)) |
| 114 | + |
| 115 | + def stop_session(self): |
| 116 | + """Stop the BGP session.""" |
| 117 | + logging.debug("stop bgp session %s", self.name) |
| 118 | + self.duthost.shell("redis-cli -n 4 -c DEL 'BGP_NEIGHBOR|%s'" % self.ip) |
| 119 | + self.duthost.shell("redis-cli -n 4 -c DEL 'DEVICE_NEIGHBOR_METADATA|%s'" % self.name) |
| 120 | + self.ptfhost.exabgp(name=self.name, state="absent") |
| 121 | + self.ptfhost.shell("ifconfig %s 0.0.0.0" % self.iface) |
| 122 | + |
| 123 | + # TODO: let's put those BGP utilities function in a common place. |
| 124 | + def announce_route(self, route): |
| 125 | + if "aspath" in route: |
| 126 | + msg = "announce route {prefix} next-hop {nexthop} as-path [ {aspath} ]" |
| 127 | + else: |
| 128 | + msg = "announce route {prefix} next-hop {nexthop}" |
| 129 | + msg = msg.format(**route) |
| 130 | + logging.debug("announce route: %s", msg) |
| 131 | + url = "http://%s:%d" % (self.ptfip, self.port) |
| 132 | + resp = requests.post(url, data={"commands": msg}) |
| 133 | + logging.debug("announce return: %s", resp) |
| 134 | + assert resp.status_code == 200 |
| 135 | + |
| 136 | + def withdraw_route(self, route): |
| 137 | + if "aspath" in route: |
| 138 | + msg = "withdraw route {prefix} next-hop {nexthop} as-path [ {aspath} ]" |
| 139 | + else: |
| 140 | + msg = "withdraw route {prefix} next-hop {nexthop}" |
| 141 | + msg = msg.format(**route) |
| 142 | + logging.debug("withdraw route: %s", msg) |
| 143 | + url = "http://%s:%d" % (self.ptfip, self.port) |
| 144 | + resp = requests.post(url, data={"commands": msg}) |
| 145 | + logging.debug("withdraw return: %s", resp) |
| 146 | + assert resp.status_code == 200 |
| 147 | + |
| 148 | + |
| 149 | +@contextlib.contextmanager |
| 150 | +def log_bgp_updates(duthost, iface, save_path): |
| 151 | + """Capture bgp packets to file.""" |
| 152 | + start_pcap = "tcpdump -i %s -w %s port 179" % (iface, save_path) |
| 153 | + stop_pcap = "pkill -f '%s'" % start_pcap |
| 154 | + start_pcap = "nohup %s &" % start_pcap |
| 155 | + duthost.shell(start_pcap) |
| 156 | + try: |
| 157 | + yield |
| 158 | + finally: |
| 159 | + duthost.shell(stop_pcap, module_ignore_errors=True) |
| 160 | + |
| 161 | + |
| 162 | +@pytest.fixture |
| 163 | +def is_quagga(duthost): |
| 164 | + """Return True if current bgp is using Quagga.""" |
| 165 | + show_res = duthost.shell("vtysh -c 'show version'") |
| 166 | + return "Quagga" in show_res["stdout"] |
| 167 | + |
| 168 | + |
| 169 | +@pytest.fixture |
| 170 | +def common_setup_teardown(duthost, is_quagga, ptfhost): |
| 171 | + mg_facts = duthost.minigraph_facts(host=duthost.hostname)["ansible_facts"] |
| 172 | + |
| 173 | + dut_asn = mg_facts["minigraph_bgp_asn"] |
| 174 | + dut_lo_addr = mg_facts["minigraph_lo_interfaces"][0]["addr"] |
| 175 | + dut_mgmt_iface = mg_facts["minigraph_mgmt_interface"]["alias"] |
| 176 | + dut_mgmt_addr = mg_facts["minigraph_mgmt_interface"]["addr"] |
| 177 | + bgp_neighbors = ( |
| 178 | + BGPNeighbor( |
| 179 | + duthost, |
| 180 | + ptfhost, |
| 181 | + "pseudoswitch0", |
| 182 | + "mgmt:0", |
| 183 | + "10.10.10.10", |
| 184 | + NEIGHBOR_ASN0, |
| 185 | + dut_lo_addr, |
| 186 | + dut_asn, |
| 187 | + NEIGHBOR_PORT0, |
| 188 | + is_quagga=is_quagga |
| 189 | + ), |
| 190 | + BGPNeighbor( |
| 191 | + duthost, |
| 192 | + ptfhost, |
| 193 | + "pseudoswitch1", |
| 194 | + "mgmt:1", |
| 195 | + "10.10.10.11", |
| 196 | + NEIGHBOR_ASN1, |
| 197 | + dut_lo_addr, |
| 198 | + dut_asn, |
| 199 | + NEIGHBOR_PORT1, |
| 200 | + is_quagga=is_quagga |
| 201 | + ) |
| 202 | + ) |
| 203 | + |
| 204 | + add_route_tmpl = "ip route add %s/32 via %s dev %s" |
| 205 | + ptfhost.shell(add_route_tmpl % (dut_lo_addr, dut_mgmt_addr, "mgmt")) |
| 206 | + duthost.shell(add_route_tmpl % (bgp_neighbors[0].ip, bgp_neighbors[0].ptfip, dut_mgmt_iface)) |
| 207 | + duthost.shell(add_route_tmpl % (bgp_neighbors[1].ip, bgp_neighbors[0].ptfip, dut_mgmt_iface)) |
| 208 | + |
| 209 | + yield bgp_neighbors, dut_mgmt_iface |
| 210 | + |
| 211 | + flush_route_tmpl = "ip route flush %s/32" |
| 212 | + ptfhost.shell(flush_route_tmpl % dut_lo_addr) |
| 213 | + duthost.shell(flush_route_tmpl % bgp_neighbors[0].ip) |
| 214 | + duthost.shell(flush_route_tmpl % bgp_neighbors[1].ip) |
| 215 | + duthost.shell("sonic-clear arp") |
| 216 | + |
| 217 | + |
| 218 | +@pytest.fixture |
| 219 | +def constants(is_quagga, ptfhost): |
| 220 | + class _C(object): |
| 221 | + """Dummy class to save test constants.""" |
| 222 | + pass |
| 223 | + |
| 224 | + _constants = _C() |
| 225 | + if is_quagga: |
| 226 | + _constants.sleep_interval = 40 |
| 227 | + _constants.update_interval_threshold = 20 |
| 228 | + else: |
| 229 | + _constants.sleep_interval = 5 |
| 230 | + _constants.update_interval_threshold = 1 |
| 231 | + |
| 232 | + _constants.routes = [] |
| 233 | + ptfip = ptfhost.host.options["inventory_manager"].get_host(ptfhost.hostname).vars["ansible_host"] |
| 234 | + for subnet in ANNOUNCED_SUBNETS: |
| 235 | + _constants.routes.append( |
| 236 | + {"prefix": subnet, "nexthop": ptfip} |
| 237 | + ) |
| 238 | + return _constants |
| 239 | + |
| 240 | + |
| 241 | +def test_bgp_update_timer(common_setup_teardown, constants, duthost): |
| 242 | + |
| 243 | + def bgp_update_packets(pcap_file): |
| 244 | + """Get bgp update packets from pcap file.""" |
| 245 | + packets = sniff( |
| 246 | + offline=pcap_file, |
| 247 | + lfilter=lambda p: bgp.BGPHeader in p and p[bgp.BGPHeader].type == 2 |
| 248 | + ) |
| 249 | + return packets |
| 250 | + |
| 251 | + def match_bgp_update(packet, src_ip, dst_ip, action, route): |
| 252 | + """Check if the bgp update packet matches.""" |
| 253 | + if not (packet[IP].src == src_ip and packet[IP].dst == dst_ip): |
| 254 | + return False |
| 255 | + subnet = ipaddress.ip_network(route["prefix"].decode()) |
| 256 | + _route = (subnet.prefixlen, str(subnet.network_address)) |
| 257 | + bgp_fields = packet[bgp.BGPUpdate].fields |
| 258 | + if action == "announce": |
| 259 | + return "nlri" in bgp_fields and _route in bgp_fields["nlri"] |
| 260 | + elif action == "withdraw": |
| 261 | + return _route in bgp_fields["withdrawn"] |
| 262 | + else: |
| 263 | + return False |
| 264 | + |
| 265 | + (n0, n1), dut_mgmt_iface = common_setup_teardown |
| 266 | + try: |
| 267 | + n0.start_session() |
| 268 | + n1.start_session() |
| 269 | + |
| 270 | + # sleep till new sessions are steady |
| 271 | + time.sleep(30) |
| 272 | + |
| 273 | + # ensure new sessions are ready |
| 274 | + bgp_facts = duthost.bgp_facts()["ansible_facts"] |
| 275 | + assert n0.ip in bgp_facts["bgp_neighbors"] |
| 276 | + assert n1.ip in bgp_facts["bgp_neighbors"] |
| 277 | + assert bgp_facts["bgp_neighbors"][n0.ip]["state"] == "established" |
| 278 | + assert bgp_facts["bgp_neighbors"][n1.ip]["state"] == "established" |
| 279 | + |
| 280 | + announce_intervals = [] |
| 281 | + withdraw_intervals = [] |
| 282 | + for i, route in enumerate(constants.routes): |
| 283 | + bgp_pcap = BGP_LOG_TMPL % i |
| 284 | + with log_bgp_updates(duthost, dut_mgmt_iface, bgp_pcap): |
| 285 | + n0.announce_route(route) |
| 286 | + time.sleep(constants.sleep_interval) |
| 287 | + n0.withdraw_route(route) |
| 288 | + time.sleep(constants.sleep_interval) |
| 289 | + |
| 290 | + with tempfile.NamedTemporaryFile() as tmp_pcap: |
| 291 | + duthost.fetch(src=bgp_pcap, dest=tmp_pcap.name, flat=True) |
| 292 | + bgp_updates = bgp_update_packets(tmp_pcap.name) |
| 293 | + |
| 294 | + announce_from_n0_to_dut = [] |
| 295 | + announce_from_dut_to_n1 = [] |
| 296 | + withdraw_from_n0_to_dut = [] |
| 297 | + withdraw_from_dut_to_n1 = [] |
| 298 | + for bgp_update in bgp_updates: |
| 299 | + if match_bgp_update(bgp_update, n0.ip, n0.peer_ip, "announce", route): |
| 300 | + announce_from_n0_to_dut.append(bgp_update) |
| 301 | + continue |
| 302 | + if match_bgp_update(bgp_update, n1.peer_ip, n1.ip, "announce", route): |
| 303 | + announce_from_dut_to_n1.append(bgp_update) |
| 304 | + continue |
| 305 | + if match_bgp_update(bgp_update, n0.ip, n0.peer_ip, "withdraw", route): |
| 306 | + withdraw_from_n0_to_dut.append(bgp_update) |
| 307 | + continue |
| 308 | + if match_bgp_update(bgp_update, n1.peer_ip, n1.ip, "withdraw", route): |
| 309 | + withdraw_from_dut_to_n1.append(bgp_update) |
| 310 | + |
| 311 | + err_msg = "no bgp update %s route %s from %s to %s" |
| 312 | + no_update = False |
| 313 | + if not announce_from_n0_to_dut: |
| 314 | + err_msg %= ("announce", route, n0.ip, n0.peer_ip) |
| 315 | + no_update = True |
| 316 | + elif not announce_from_dut_to_n1: |
| 317 | + err_msg %= ("announce", route, n1.peer_ip, n1.ip) |
| 318 | + no_update = True |
| 319 | + elif not withdraw_from_n0_to_dut: |
| 320 | + err_msg %= ("withdraw", route, n0.ip, n0.peer_ip) |
| 321 | + no_update = True |
| 322 | + elif not withdraw_from_dut_to_n1: |
| 323 | + err_msg %= ("withdraw", route, n1.peer_ip, n1.ip) |
| 324 | + no_update = True |
| 325 | + if no_update: |
| 326 | + pytest.fail(err_msg) |
| 327 | + |
| 328 | + announce_intervals.append( |
| 329 | + announce_from_dut_to_n1[0].time - announce_from_n0_to_dut[0].time |
| 330 | + ) |
| 331 | + withdraw_intervals.append( |
| 332 | + withdraw_from_dut_to_n1[0].time - withdraw_from_n0_to_dut[0].time |
| 333 | + ) |
| 334 | + |
| 335 | + logging.debug("announce updates intervals: %s", announce_intervals) |
| 336 | + logging.debug("withdraw updates intervals: %s", withdraw_intervals) |
| 337 | + |
| 338 | + mi = (len(constants.routes) - 1) // 2 |
| 339 | + announce_intervals.sort() |
| 340 | + withdraw_intervals.sort() |
| 341 | + err_msg = "%s updates interval exceeds threshold %d" |
| 342 | + if announce_intervals[mi] >= constants.update_interval_threshold: |
| 343 | + pytest.fail(err_msg % ("announce", constants.update_interval_threshold)) |
| 344 | + if withdraw_intervals[mi] >= constants.update_interval_threshold: |
| 345 | + pytest.fail(err_msg % ("withdraw", constants.update_interval_threshold)) |
| 346 | + |
| 347 | + finally: |
| 348 | + n0.stop_session() |
| 349 | + n1.stop_session() |
| 350 | + for route in constants.routes: |
| 351 | + duthost.shell("ip route flush %s" % route["prefix"]) |
0 commit comments