From 55a828d08276842dee03aa84384f8312591c61b9 Mon Sep 17 00:00:00 2001 From: Ashok Kumar P Date: Mon, 25 May 2026 08:11:08 -0700 Subject: [PATCH] ARS test script Description of PR New test plan for Adaptive Routing and Switching HLD [sonic-net/SONiC#1958] Summary: Fixes # (issue) Type of change Bug fix Testbed and Framework(new/improvement) New Test case Skipped for non-supported platforms Test case improvement Back port request 202311 202405 202411 202505 202511 202512 202605 Approach What is the motivation for this PR? New test plan for ARS HLD [sonic-net/SONiC#1958] How did you do it? Added ARS test plan covering adaptive routing and switching functionality. How did you verify/test it? Ran it on the device Results - ecmp/ars/test_ars.py::test_ars_modes[per-packet-global] PASSED [ 10%] ecmp/ars/test_ars.py::test_ars_modes[per-packet-interface] PASSED [ 20%] ecmp/ars/test_ars.py::test_ars_modes[per-packet-nexthop] PASSED [ 30%] ecmp/ars/test_ars.py::test_ars_modes[per-flowlet-global] PASSED [ 40%] ecmp/ars/test_ars.py::test_ars_modes[per-flowlet-interface] PASSED [ 50%] ecmp/ars/test_ars.py::test_ars_modes[per-flowlet-nexthop] PASSED [ 60%] ecmp/ars/test_ars.py::test_ars_acl_action PASSED [ 70%] ecmp/ars/test_ars.py::test_ars_nonars_interface[interface] PASSED [ 80%] ecmp/ars/test_ars.py::test_ars_nonars_interface[nexthop] PASSED [ 90%] ecmp/ars/test_ars.py::test_ars_stress PASSED [100%] Any platform specific information? To be supported for ARS supported platforms and now the script is supported only for marvell-teralynx Supported testbed topology if it's a new test case? T0 Topology Signed-off-by: Ashok Kumar P --- .../roles/test/files/ptftests/py3/arstest.py | 231 +++++++++ .../tests_mark_conditions.yaml | 10 +- tests/ecmp/ars/__init__.py | 0 tests/ecmp/ars/acl.json | 36 ++ tests/ecmp/ars/ars.json | 50 ++ tests/ecmp/ars/conftest.py | 25 + tests/ecmp/ars/test_ars.py | 446 ++++++++++++++++++ 7 files changed, 797 insertions(+), 1 deletion(-) create mode 100644 ansible/roles/test/files/ptftests/py3/arstest.py create mode 100644 tests/ecmp/ars/__init__.py create mode 100644 tests/ecmp/ars/acl.json create mode 100644 tests/ecmp/ars/ars.json create mode 100644 tests/ecmp/ars/conftest.py create mode 100644 tests/ecmp/ars/test_ars.py diff --git a/ansible/roles/test/files/ptftests/py3/arstest.py b/ansible/roles/test/files/ptftests/py3/arstest.py new file mode 100644 index 00000000000..1e081c1c313 --- /dev/null +++ b/ansible/roles/test/files/ptftests/py3/arstest.py @@ -0,0 +1,231 @@ +# ars_ecmp_test.py + +import logging +import json +import time +import subprocess +from scapy.utils import wrpcap +import ptf +from scapy.all import sendp + +from ptf.base_tests import BaseTest +from ptf.mask import Mask +from ptf.packet import Ether, IP +from ptf.testutils import ( + send_packet, + verify_packet_any_port, + simple_tcp_packet, + test_params_get, +) + +RESULT_FILE = "/tmp/ars_ptf_result.json" + +# Idle time between burst of packets. +FLOWLET_IDLE_TIME_SECONDS = 0.1 + + +class ArsTest(BaseTest): + """ + ARS ECMP Test with FLOWLET behavior: + + Burst1 → should stick to one port + Idle time → new flowlet + Burst2 → MUST switch to a DIFFERENT port + """ + + def __init__(self): + BaseTest.__init__(self) + + def setUp(self): + BaseTest.setUp(self) + self.dataplane = ptf.dataplane_instance + self.params = test_params_get() + + self.router_mac = self.params["router_mac"] + self.packet_count = int(self.params.get("packet_count")) + self.test_case = self.params.get("test_case") + + self.ingress_port = int(self.params.get("ingress_port")) + self.egress_ports = self.params.get("egress_ports") + self.negative = self.params.get("negative") + + # Detect mode automatically + self.mode = "flowlet" if "flowlet" in self.test_case.lower() else "per_packet" + + logging.info(f"=== ARS Test Case: {self.test_case} ===") + logging.info(f"Mode: {self.mode}") + logging.info(f"Packets per flowlet: {self.packet_count}") + logging.info(f"Ingress port: {self.ingress_port}") + logging.info(f"Egress ports: {self.egress_ports}") + + # Counters + self.rx_counters = {str(p): 0 for p in self.egress_ports} + + # For flowlet tracking (Burst1_port, Burst2_port) + self.flowlet_ports = [] + + # ---------------------------------------------------------- + # Fixed-flow packet generator + # ---------------------------------------------------------- + def _generate_packet(self): + src_ip = "10.3.3.2" + sport = 4000 + dst_ip = "193.1.176.10" + dport = 5000 + + src_mac = self.dataplane.get_mac(0, self.ingress_port) + + return simple_tcp_packet( + eth_dst=self.router_mac, + eth_src=src_mac, + ip_src=src_ip, + ip_dst=dst_ip, + tcp_sport=sport, + tcp_dport=dport, + ip_ttl=64, + ) + + # ---------------------------------------------------------- + # MASK + verify any port for per-packet + # ---------------------------------------------------------- + def _verify_and_record(self, pkt): + masked = Mask(pkt) + masked.set_do_not_care_scapy(Ether, "src") + masked.set_do_not_care_scapy(Ether, "dst") + masked.set_do_not_care_scapy(IP, "ttl") + masked.set_do_not_care_scapy(IP, "chksum") + masked.set_do_not_care_scapy(IP, "tos") + masked.set_do_not_care_scapy(IP, "id") + masked.set_do_not_care_scapy(IP, "flags") + masked.set_do_not_care_scapy(IP, "frag") + masked.set_do_not_care_scapy(IP, "len") + + rv = verify_packet_any_port( + self, masked, ports=self.egress_ports, timeout=1 + ) + + if isinstance(rv, tuple): + idx, _ = rv + if idx >= 0: + port = self.egress_ports[idx] + self.rx_counters[str(port)] += 1 + + # ---------------------------------------------------------- + # sendpfast burst sender for per-flowlet + # ---------------------------------------------------------- + def _send_flowlet_burst(self, pkt, count=5000, pps=10000): + pcap = "/tmp/flowlet_burst.pcap" + wrpcap(pcap, [pkt] * count) + iface = f"eth{self.ingress_port}" + + sendp([pkt] * count, iface=iface, inter=1 / pps, verbose=False) + + # short delay for counters to update + time.sleep(0.1) + + def runTest(self): + if self.mode == "per_packet": + self._run_per_packet() + else: + self._run_flowlet() + + # Save PTF result + result = { + "test_case": self.test_case, + "mode": self.mode, + "flowlet_ports": self.flowlet_ports, + } + with open(RESULT_FILE, "w") as fp: + json.dump(result, fp) + + logging.info(f"Saved → {RESULT_FILE}") + + # ---------------------------------------------------------- + # PER-PACKET LB + # ---------------------------------------------------------- + def _run_per_packet(self): + logging.info("=== PER-PACKET test ===") + for _ in range(self.packet_count): + pkt = self._generate_packet() + send_packet(self, self.ingress_port, pkt) + self._verify_and_record(pkt) + self._check_per_packet_balancing() + + # ---------------------------------------------------------- + # PER-PACKET Validation + # ---------------------------------------------------------- + def _check_per_packet_balancing(self): + total = sum(self.rx_counters[str(p)] for p in self.egress_ports) + expected = total / len(self.egress_ports) + tolerance = expected * 0.40 + + for p in self.egress_ports: + r = self.rx_counters[str(p)] + if abs(r - expected) > tolerance: + if self.negative: + logging.warning( + f"[PER-PACKET][NEGATIVE] Port {p} unbalanced as expected: {r}" + ) + return # do NOT assert, test passes + else: + raise AssertionError(f"[PER-PACKET] Port {p} unbalanced: {r}") + + logging.info("[PER-PACKET] Load-balancing OK") + + # ---------------------------------------------------------- + # FLOWLET MODE (with TX counter detection) + # ---------------------------------------------------------- + def _run_flowlet(self): + pkt = self._generate_packet() + + # ------------------------- + # Flowlet Burst 1 + # ------------------------- + + tx_before = {p: ArsTest.get_tx_count(f"eth{p}") for p in self.egress_ports} + + logging.info("[FLOWLET] >>> Burst 1") + self._send_flowlet_burst(pkt, count=self.packet_count, pps=10000) + + p1, counts = ArsTest.get_flowlet_port(self.egress_ports, tx_before) + self.flowlet_ports.append(p1) + logging.info(f"[FLOWLET] Burst finished, port used: {p1}, counts: {counts}") + + idle = FLOWLET_IDLE_TIME_SECONDS + logging.info(f"[FLOWLET] Sleeping {idle}s for new flowlet") + time.sleep(idle) + + # ------------------------- + # Flowlet Burst 2 + # ------------------------- + tx_before = {p: ArsTest.get_tx_count(f"eth{p}") for p in self.egress_ports} + logging.info("[FLOWLET] >>> Burst 2") + self._send_flowlet_burst(pkt, count=self.packet_count, pps=10000) + p2, counts = ArsTest.get_flowlet_port(self.egress_ports, tx_before) + self.flowlet_ports.append(p2) + logging.info(f"[FLOWLET] Burst finished, port used: {p2}, counts: {counts}") + + self._check_flowlet_switch(p1, p2) + + def _check_flowlet_switch(self, p1, p2): + if p1 == p2: + raise AssertionError(f"[FLOWLET] NO SWITCH: Both flowlets used port {p1}") + logging.info(f"[FLOWLET] SUCCESS — switched ports {p1} → {p2}") + + @staticmethod + def get_tx_count(iface): + cmd = f"cat /sys/class/net/{iface}/statistics/rx_packets" + output = subprocess.check_output(cmd, shell=True) + return int(output) + + @staticmethod + def get_flowlet_port(egress_ports, tx_before): + port_counts = {} + for port in egress_ports: + iface = f"eth{port}" # adjust if your port naming differs + tx_after = ArsTest.get_tx_count(iface) + port_counts[port] = tx_after - tx_before.get(port, 0) + + best_port = max(port_counts, key=lambda k: port_counts[k]) + logging.info(f"[FLOWLET] Burst went out port {best_port}, count={port_counts[best_port]}") + return best_port, port_counts diff --git a/tests/common/plugins/conditional_mark/tests_mark_conditions.yaml b/tests/common/plugins/conditional_mark/tests_mark_conditions.yaml index bbe58242e74..b94e78494e7 100644 --- a/tests/common/plugins/conditional_mark/tests_mark_conditions.yaml +++ b/tests/common/plugins/conditional_mark/tests_mark_conditions.yaml @@ -1582,6 +1582,15 @@ ecmp/: conditions: - "'bmc' in topo_type" +ecmp/ars/test_ars.py: + skip: + conditions_logical_operator: or + reason: "The test case runs on Marvell-teralynx." + conditions: + - "platform not in ['x86_64-marvell_dbmvtx9180-r0', 'x86_64-marvell_d64p512t-r0']" + - "topo_type not in ['t0']" + - "asic_type not in ['marvell-teralynx']" + ecmp/inner_hashing/test_inner_hashing.py: skip: conditions_logical_operator: or @@ -1662,7 +1671,6 @@ ecmp/test_fgnhg.py: - "https://github.com/sonic-net/sonic-mgmt/issues/7755" - "https://github.com/sonic-net/sonic-mgmt/issues/6558 and 'msn2' in platform" -####################################### ##### everflow ##### ####################################### diff --git a/tests/ecmp/ars/__init__.py b/tests/ecmp/ars/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/ecmp/ars/acl.json b/tests/ecmp/ars/acl.json new file mode 100644 index 00000000000..d984b1c74d9 --- /dev/null +++ b/tests/ecmp/ars/acl.json @@ -0,0 +1,36 @@ +{ + "ACL_TABLE_TYPE": { + "CUSTOM_1_ARS": { + "MATCHES": [ + "SRC_IP" + ], + "ACTIONS": [ + "DISABLE_ARS_FORWARDING", + "COUNTER" + ], + "BIND_POINTS": [ + "PORT" + ] + } + }, + "ACL_TABLE": { + "MY_ACL_1": { + "policy_desc": "Disable ARS operation", + "type": "CUSTOM_1_ARS", + "ports": [ + "PortChannel101", + "PortChannel102", + "PortChannel103", + "PortChannel104" + ], + "stage": "ingress" + } + }, + "ACL_RULE": { + "MY_ACL_1|NO_ARS": { + "SRC_IP": "10.3.3.2/24", + "DISABLE_ARS_FORWARDING": "DROP", + "PRIORITY": "100" + } + } +} diff --git a/tests/ecmp/ars/ars.json b/tests/ecmp/ars/ars.json new file mode 100644 index 00000000000..6d626f1e0f0 --- /dev/null +++ b/tests/ecmp/ars/ars.json @@ -0,0 +1,50 @@ +{ + "ARS_PROFILE": { + "ars_profile_default": { + "algorithm": "ewma", + "ars_nhg_path_selector_mode": "global", + "default_ars_object": "ars_obj_name", + "ipv4_enable": "true", + "ipv6_enable": "true" + } + }, + "ARS_OBJECT": { + "ars_obj_name": { + "assign_mode": "per_packet_quality", + "flowlet_idle_time": "1000", + "max_flows": "512" + } + }, + "ARS_NEXTHOP": { + "|10.0.0.57": { + "ars_obj_name": "ars_obj_name" + }, + "|10.0.0.59": { + "ars_obj_name": "ars_obj_name" + }, + "|10.0.0.61": { + "ars_obj_name": "ars_obj_name" + }, + "|10.0.0.63": { + "ars_obj_name": "ars_obj_name" + } + }, + "ARS_INTERFACE": { + "Ethernet28": { + "scaling_factor": "1", + "ars_obj_name": "ars_obj_name" + }, + "Ethernet29": { + "scaling_factor": "1", + "ars_obj_name": "ars_obj_name" + }, + "Ethernet30": { + "scaling_factor": "1", + "ars_obj_name": "ars_obj_name" + }, + "Ethernet31": { + "scaling_factor": "1", + "ars_obj_name": "ars_obj_name" + } + } +} diff --git a/tests/ecmp/ars/conftest.py b/tests/ecmp/ars/conftest.py new file mode 100644 index 00000000000..23354143f04 --- /dev/null +++ b/tests/ecmp/ars/conftest.py @@ -0,0 +1,25 @@ +import pytest +import json +import os + + +@pytest.fixture(scope="session") +def base_ars_config(): + base_dir = os.path.dirname(__file__) + ars_file = os.path.join(base_dir, "ars.json") + with open(ars_file) as f: + return json.load(f) + + +@pytest.fixture(scope="session") +def acl_config(): + base_dir = os.path.dirname(__file__) + acl_file = os.path.join(base_dir, "acl.json") + with open(acl_file) as f: + return json.load(f) + + +@pytest.fixture(scope="module") +def router_mac(duthosts, rand_one_dut_hostname): + duthost = duthosts[rand_one_dut_hostname] + return duthost.facts["router_mac"] diff --git a/tests/ecmp/ars/test_ars.py b/tests/ecmp/ars/test_ars.py new file mode 100644 index 00000000000..af3a02f5f44 --- /dev/null +++ b/tests/ecmp/ars/test_ars.py @@ -0,0 +1,446 @@ +# Summary: ARS test +# How to run this test: sudo ./run_tests.sh -n -i \ +# -u -m group -e --skip_sanity -l info -c ecmp/test_ars.py + +import logging +import pytest +import json +import tempfile +import time +import os +import copy +import re + + +from datetime import datetime +from tests.ptf_runner import ptf_runner +from tests.common.config_reload import config_reload + +logger = logging.getLogger(__name__) + +pytestmark = [ + pytest.mark.topology('t0'), + pytest.mark.disable_loganalyzer +] + +RESULTS_FILE = "/tmp/ars_test_results.json" +PACKET_COUNT = 1000 +TRAFFIC_VARIATIONS = 1 + +# ------------------------------------------------------------ +# Helper Functions +# ------------------------------------------------------------ + + +@pytest.fixture(scope="function") +def create_rif(duthost, tbinfo): + rif_port, rif_ptf = get_ingress_ptf_port(duthost, tbinfo, vlan_name="Vlan1000") + rif_ip = "10.3.3.1/24" + + cmds_create = [ + f"config vlan member del 1000 {rif_port}", + f"config interface ip add {rif_port} {rif_ip}", + ] + + cmds_remove = [ + f"config interface ip remove {rif_port} {rif_ip}", + f"config vlan member add -u 1000 {rif_port}" + ] + + for cmd in cmds_create: + duthost.shell(cmd) + + yield + + # Cleanup + time.sleep(1) + for cmd in cmds_remove: + duthost.shell(cmd) + config_reload(duthost, safe_reload=True) + time.sleep(5) + + +def update_scaling_factor(duthost, port, value): + duthost.shell( + f"sonic-db-cli CONFIG_DB HSET 'ARS_INTERFACE|{port}' 'scaling_factor' '{value}'" + ) + time.sleep(2) + + +def write_temp_config(duthost, config_data): + with tempfile.NamedTemporaryFile(mode="w+", encoding="utf-8", + delete=False, suffix=".json") as tmp_local: + json.dump(config_data, tmp_local, indent=4) + tmp_local.flush() + tmp_name = tmp_local.name + + duthost.copy(src=tmp_name, dest="/tmp/ars_config.json") + os.unlink(tmp_name) + + return "/tmp/ars_config.json" + + +def load_config(duthost): + logging.debug("Reloading SONiC configuration via JSON...") + duthost.shell("config load -y /tmp/ars_config.json") + time.sleep(10) # allow services to restart + + +def update_ars_interface_ports(cfg, egress_ports): + new_table = {} + + for port in egress_ports: + new_table[str(port)] = { + "scaling_factor": "1", + "ars_obj_name": "ars_obj_name" + } + + cfg["ARS_INTERFACE"] = new_table + + +def configure_ars_from_json(duthost, base_cfg, nhg_mode, assign_mode, egress_ports): + """ + Load ars.json, override fields based on test case requirement, + generate a temporary config, and reload device. + """ + cfg = copy.deepcopy(base_cfg) + + logging.debug(f"Applying ARS config from ars.json with overrides: " + f"nhg_mode={nhg_mode}, assign_mode={assign_mode}") + + if "ARS_PROFILE" in cfg: + for profile_name, profile in cfg["ARS_PROFILE"].items(): + profile["ars_nhg_path_selector_mode"] = nhg_mode + + if "ARS_OBJECT" in cfg: + for obj_name, obj in cfg["ARS_OBJECT"].items(): + obj["assign_mode"] = assign_mode + update_ars_interface_ports(cfg, egress_ports) + write_temp_config(duthost, cfg) + load_config(duthost) + + logging.debug("ARS JSON configuration applied successfully.") + + +def configure_acl_from_json(duthost, acl_cfg): + """ + Load acl.json, generate a temporary ACL config file on the DUT, + and reload the ACL configuration. + """ + logging.debug("Applying ACL configuration from acl.json") + + write_temp_config(duthost, acl_cfg) + load_config(duthost) + + logging.debug("ACL JSON configuration applied successfully.") + + +def verify_bgp_ecmp(duthost): + """ + Verify BGP ECMP exist in DUT. + 1) Ensures all BGP neighbors are in Established state by checking uptime formats. + 2) Ensures ECMP routes exist. + """ + bgp_summary = duthost.shell("show ip bgp summary")["stdout"] + + neighbor_lines = [ + line for line in bgp_summary.splitlines() + if re.match(r"^\d+\.\d+\.\d+\.\d+", line.strip()) + ] + + if not neighbor_lines: + raise AssertionError("No BGP neighbors found") + + up_down_regex = re.compile( + r"^(?:\d{2}:\d{2}:\d{2}|\d+d\d+h\d+m?|\d+w\d+d)$" + ) + + for line in neighbor_lines: + parts = line.split() + up_down = parts[-3] + + if not up_down_regex.match(up_down): + raise AssertionError(f"BGP neighbor down: {line}") + + routes_output = duthost.shell("show ip route")["stdout"] + + if not has_ecmp_routes(routes_output): + raise AssertionError("No ECMP routes installed") + + +def has_ecmp_routes(routes_output): + """ + Detects ECMP routes in 'show ip route' output. + ECMP = 2 or more 'via' nexthops under the same prefix. + """ + + lines = routes_output.splitlines() + via_count = 0 + + for line in lines: + stripped = line.strip() + + if re.match(r"^[A-Z\*]>", stripped): + if via_count > 1: + return True + via_count = 1 if " via " in stripped else 0 + continue + + if stripped.startswith("*") or stripped.startswith("via "): + if " via " in stripped: + via_count += 1 + continue + + return via_count > 1 + + +def get_first_3_pc_member_ptf_ports(duthost, tbinfo): + mg = duthost.get_extended_minigraph_facts(tbinfo) + pcs = mg.get("minigraph_portchannels", {}) or {} + ptf_indices = mg.get("minigraph_ptf_indices", {}) or {} + + dut_ports = [] + ptf_ports = [] + + for pc_name in sorted(pcs.keys()): + members = (pcs[pc_name] or {}).get("members", []) + for member in sorted(members): + if member in ptf_indices: + dut_ports.append(member) + ptf_ports.append(ptf_indices[member]) + if len(dut_ports) == 3: + return dut_ports, ptf_ports + + return dut_ports, ptf_ports + + +def get_ingress_ptf_port(duthost, tbinfo, vlan_name="Vlan1000"): + mg = duthost.get_extended_minigraph_facts(tbinfo) + vlans = mg.get("minigraph_vlans", {}) or {} + ptf_indices = mg.get("minigraph_ptf_indices", {}) or {} + + if vlan_name not in vlans: + raise RuntimeError(f"VLAN {vlan_name} not found in minigraph_vlans") + + members = vlans[vlan_name].get("members", []) + if not members: + raise RuntimeError(f"No members found for VLAN {vlan_name}") + + for member in sorted(members): + if member in ptf_indices: + return member, ptf_indices[member] + + return members[0], None + + +def run_ptf_ars_test(request, duthost, ptfhost, tbinfo, nhg_mode, assign_mode, router_mac, negative): + """ + Run the ARS ECMP PTF test on the PTF host. + + """ + timestamp = datetime.now().strftime('%Y-%m-%d-%H:%M:%S') + + logging.info(f"Running ARS ECMP test: NHG={nhg_mode}, assign={assign_mode}") + log_file = "/tmp/ars_ecmp_test.arsTest.{}.log"\ + .format(timestamp) + logging.debug("PTF log file: %s" % log_file) + ingress_member, ingress_port = get_ingress_ptf_port(duthost, tbinfo) + egress_ports, ptf_ports = get_first_3_pc_member_ptf_ports(duthost, tbinfo) + # Prepare PTF parameters + ptf_params = { + "test_case": f"ars_{nhg_mode}_{assign_mode}", + "router_mac": router_mac, + "packet_count": PACKET_COUNT, + "ingress_port": ingress_port, + "egress_ports": ptf_ports, + "hash_keys": ["src-ip"], + "negative": negative + } + + # Run the PTF test using ptf_runner with Python3 mode + ptf_runner(ptfhost, + "ptftests", + "arstest.ArsTest", # module.class + platform_dir="ptftests", + params=ptf_params, + log_file=log_file, + qlen=2000, + socket_recv_size=16384, + is_python3=True) + + logging.info(f"PTF ARS test finished: NHG={nhg_mode}, assign={assign_mode}") + + +def save_results(data): + with open(RESULTS_FILE, "w") as f: + json.dump(data, f, indent=4) + logging.debug(f"Results saved to {RESULTS_FILE}") + + +@pytest.mark.parametrize("nhg_mode", ["global", "interface", "nexthop"]) +@pytest.mark.parametrize("assign_mode", ["per_packet_quality", "per_flowlet_quality"]) +def test_ars_modes(request, duthost, ptfhost, tbinfo, base_ars_config, nhg_mode, assign_mode, router_mac, create_rif): + """ + Verify ARS ECMP using ARS config loaded from ars.json, + with dynamic overrides (nhg_mode + assign_mode). + """ + mg_facts = duthost.get_extended_minigraph_facts(tbinfo) + pc_members = [] + for _, pc_data in (mg_facts.get("minigraph_portchannels", {}) or {}).items(): + pc_members.extend(pc_data.get("members", [])) + last_eth = pc_members[-1] + # Apply ARS config from JSON + egress_ports, ptf_ports = get_first_3_pc_member_ptf_ports(duthost, tbinfo) + configure_ars_from_json( + duthost=duthost, + base_cfg=base_ars_config, + nhg_mode=nhg_mode, + assign_mode=assign_mode, + egress_ports=egress_ports + ) + # Verify BGP and ECMP + verify_bgp_ecmp(duthost) + # trigger creation of ARS + duthost.shell(f"sudo config interface shutdown {last_eth}") + time.sleep(10) + # Run PTF runner + run_ptf_ars_test(request, duthost, ptfhost, tbinfo, nhg_mode, assign_mode, router_mac, False) + duthost.shell(f"sudo config interface startup {last_eth}") + + save_results({ + "case": "test_ars_modes", + "nhg_mode": nhg_mode, + "assign_mode": assign_mode, + "status": "PASSED" + }) + + +def test_ars_acl_action(request, duthost, ptfhost, tbinfo, base_ars_config, router_mac, create_rif, acl_config): + """ + Verify ACL can disable ARS forwarding for specific traffic. + """ + + cfg = copy.deepcopy(base_ars_config) + + egress_ports, ptf_ports = get_first_3_pc_member_ptf_ports(duthost, tbinfo) + configure_ars_from_json( + duthost=duthost, + base_cfg=cfg, + nhg_mode="nexthop", + assign_mode="per_packet_quality", + egress_ports=egress_ports + ) + cfg = copy.deepcopy(acl_config) + configure_acl_from_json(duthost, acl_cfg=cfg) + mg_facts = duthost.get_extended_minigraph_facts(tbinfo) + pc_members = [] + for _, pc_data in (mg_facts.get("minigraph_portchannels", {}) or {}).items(): + pc_members.extend(pc_data.get("members", [])) + last_eth = pc_members[-1] + + duthost.shell(f"sudo config interface shutdown {last_eth}") + time.sleep(10) + verify_bgp_ecmp(duthost) + run_ptf_ars_test(request, duthost, ptfhost, tbinfo, "global", "per-packet", router_mac, True) + duthost.shell(f"sudo config interface startup {last_eth}") + + save_results({"case": "test_ars_acl_action", "status": "PASSED"}) + + +@pytest.mark.parametrize("nhg_mode", ["interface", "nexthop"]) +def test_ars_nonars_interface(request, duthost, ptfhost, tbinfo, base_ars_config, nhg_mode, router_mac, create_rif): + """ + Run ARS in 2 modes: + - interface : ARS applied using ARS_INTERFACE + - nexthop : ARS applied using ARS_NEXTHOP + """ + + logging.info(f"=== Running ARS Test Mode: {nhg_mode} ===") + + cfg = copy.deepcopy(base_ars_config) + + if nhg_mode == "interface": + logging.debug("ARS Mode: interface (removing ARS_INTERFACE)") + cfg.pop("ARS_INTERFACE", None) + elif nhg_mode == "nexthop": + logging.debug("ARS Mode: nexthop (removing ARS_NEXTHOP)") + cfg.pop("ARS_NEXTHOP", None) + + egress_ports, ptf_ports = get_first_3_pc_member_ptf_ports(duthost, tbinfo) + configure_ars_from_json( + duthost=duthost, + base_cfg=cfg, + nhg_mode=nhg_mode, + assign_mode="per_packet_quality", + egress_ports=egress_ports + ) + + verify_bgp_ecmp(duthost) + + mg_facts = duthost.get_extended_minigraph_facts(tbinfo) + pc_members = [] + for _, pc_data in (mg_facts.get("minigraph_portchannels", {}) or {}).items(): + pc_members.extend(pc_data.get("members", [])) + last_eth = pc_members[-1] + duthost.shell(f"sudo config interface shutdown {last_eth}") + time.sleep(10) + try: + run_ptf_ars_test( + request, + duthost, + ptfhost, tbinfo, + nhg_mode, + "per-packet", + router_mac, + True, + ) + except AssertionError: + logging.warning("Traffic check failed as per testcase") + + duthost.shell(f"sudo config interface startup {last_eth}") + save_results({ + "case": f"test_ars_{nhg_mode}", + "status": "PASSED" + }) + + +def test_ars_stress(request, duthost, ptfhost, tbinfo, base_ars_config, router_mac, create_rif): + """ + Stress test ARS ECMP under port flap and scaling factor change. + """ + egress_ports, ptf_ports = get_first_3_pc_member_ptf_ports(duthost, tbinfo) + configure_ars_from_json( + duthost=duthost, + base_cfg=base_ars_config, + nhg_mode="nexthop", + assign_mode="per_flowlet_quality", + egress_ports=egress_ports + ) + + verify_bgp_ecmp(duthost) + mg_facts = duthost.get_extended_minigraph_facts(tbinfo) + pc_members = [] + for _, pc_data in (mg_facts.get("minigraph_portchannels", {}) or {}).items(): + pc_members.extend(pc_data.get("members", [])) + last_eth = pc_members[-1] + + duthost.shell(f"sudo config interface shutdown {last_eth}") + # To install ARS ecmp + time.sleep(20) + run_ptf_ars_test(request, duthost, ptfhost, tbinfo, "nexthop", "per-flowlet", router_mac, False) + + for port in pc_members[:2]: + logger.info(f"Flapping port {port} ...") + duthost.shell(f"config interface shutdown {port}") + duthost.shell(f"config interface startup {port}") + + # update scaling factor for each PC member + for port in pc_members: + update_scaling_factor(duthost, port, 2) + # To install ARS ecmp + time.sleep(20) + + run_ptf_ars_test(request, duthost, ptfhost, tbinfo, "nexthop", "per-flowlet", router_mac, False) + duthost.shell(f"sudo config interface startup {last_eth}") + + save_results({"case": "test_ars_stress", "status": "PASSED"})