From 7ada26154b611828765cf12046cd3b0589bb43b6 Mon Sep 17 00:00:00 2001 From: Ravi Minnikanti Date: Sun, 19 Apr 2026 09:43:23 +0530 Subject: [PATCH] [LLR]: CLI implementation for Link Layer Retry feature show llr interface show llr profile show llr counters [-i] Ethernet0 show llr counters detailed Ethernet0 config llr interface mode/local/remote counterpoll llr enable/disable/interval sonic-clear llr counters sonic-clear llr counters interface Signed-off-by: Ravi Minnikanti --- clear/main.py | 26 ++ config/llr.py | 111 ++++++ config/main.py | 4 + counterpoll/main.py | 52 ++- doc/Command-Reference.md | 222 ++++++++++++ scripts/llrstat | 352 +++++++++++++++++++ setup.py | 1 + show/llr.py | 172 ++++++++++ show/main.py | 2 + tests/counterpoll_test.py | 2 + tests/llr_test.py | 532 +++++++++++++++++++++++++++++ tests/mock_tables/appl_db.json | 34 ++ tests/mock_tables/config_db.json | 21 ++ tests/mock_tables/counters_db.json | 48 ++- tests/mock_tables/state_db.json | 18 +- 15 files changed, 1593 insertions(+), 4 deletions(-) create mode 100644 config/llr.py create mode 100755 scripts/llrstat create mode 100644 show/llr.py create mode 100644 tests/llr_test.py diff --git a/clear/main.py b/clear/main.py index b4df9e48f5f..295cd7c742c 100755 --- a/clear/main.py +++ b/clear/main.py @@ -13,6 +13,7 @@ from config.plugins.pbh import serialize_pbh_counters from . import plugins from . import stp + # This is from the aliases example: # https://github.com/pallets/click/blob/57c6f09611fc47ca80db0bd010f05998b3c0aa95/examples/aliases/aliases.py class Config(object): @@ -149,6 +150,31 @@ def ipv6(): # cli.add_command(stp.spanning_tree) + +# 'LLR' +# +@cli.group(cls=clicommon.AliasedGroup) +def llr(): + """Clear LLR (Link Layer Retry) counters""" + pass + + +@llr.group(name='counters', invoke_without_command=True, cls=clicommon.AliasedGroup) +@click.pass_context +def llr_counters(ctx): + """Clear LLR counter statistics (all ports)""" + if ctx.invoked_subcommand is None: + command = ["llrstat", "-c"] + run_command(command) + + +@llr_counters.command(name='interface') +@click.argument('interface_name', metavar='') +def llr_counters_interface(interface_name): + """Clear LLR counter statistics for a specific interface""" + command = ["llrstat", "-c", "-i", str(interface_name)] + run_command(command) + # # Inserting BGP functionality into cli's clear parse-chain. # BGP commands are determined by the routing-stack being elected. diff --git a/config/llr.py b/config/llr.py new file mode 100644 index 00000000000..1e7b78e6d62 --- /dev/null +++ b/config/llr.py @@ -0,0 +1,111 @@ +import click +import utilities_common.cli as clicommon +from swsscommon.swsscommon import SonicV2Connector + +LLR_CAPABLE_KEY = "SWITCH_CAPABILITY|switch" +LLR_CAPABLE_FIELD = "LLR_CAPABLE" + + +def _check_llr_capability(): + """ + Check STATE_DB SWITCH_CAPABILITY|switch for LLR_CAPABLE == "true". + Returns True if supported, False otherwise. + """ + state_db = SonicV2Connector(host="127.0.0.1") + state_db.connect(state_db.STATE_DB) + val = state_db.get(state_db.STATE_DB, LLR_CAPABLE_KEY, LLR_CAPABLE_FIELD) + return val == "true" + + +def _validate_port_exists(db, interface_name): + """ + Validate that the given interface exists in PORT table of CONFIG_DB. + Returns True if the port exists, False otherwise. + """ + entry = db.get_entry("PORT", interface_name) + return len(entry) > 0 + + +def _validate_llr_static_mode(cfgdb, interface_name, command_name): + """ + Common validation for local/remote commands + """ + if not _check_llr_capability(): + click.echo("Error: LLR is not supported on this platform.") + raise SystemExit(1) + + if not _validate_port_exists(cfgdb, interface_name): + click.echo("Error: Interface {} does not exist.".format(interface_name)) + raise SystemExit(1) + + entry = cfgdb.get_entry("LLR_PORT", interface_name) + mode = entry.get("llr_mode", "static") + if mode != "static": + click.echo("Error: 'config llr interface {}' is only applicable " + "when llr_mode is 'static' (current mode: '{}').".format( + command_name, mode)) + raise SystemExit(1) + + +############################################################################## +# 'llr' group ("config llr ...") +############################################################################## + +@click.group(cls=clicommon.AliasedGroup) +@click.pass_context +def llr(ctx): + """Configure LLR (Link Layer Retry)""" + pass + + +############################################################################## +# 'config llr interface ...' +############################################################################## + +@llr.group(name='interface', cls=clicommon.AliasedGroup) +@click.pass_context +def llr_interface(ctx): + """Configure LLR on a per-port basis""" + pass + + +@llr_interface.command(name='mode') +@click.argument('interface_name', metavar='') +@click.argument('llr_mode', metavar='', type=click.Choice(['static'])) +@click.pass_context +def llr_interface_mode(ctx, interface_name, llr_mode): + """Configure LLR mode on an interface""" + if not _check_llr_capability(): + click.echo("Error: LLR is not supported on this platform.") + raise SystemExit(1) + + cfgdb = ctx.obj.cfgdb + if not _validate_port_exists(cfgdb, interface_name): + click.echo("Error: Interface {} does not exist.".format(interface_name)) + raise SystemExit(1) + + cfgdb.mod_entry("LLR_PORT", interface_name, {"llr_mode": llr_mode}) + + +@llr_interface.command(name='local') +@click.argument('interface_name', metavar='') +@click.argument('state', metavar='{enabled|disabled}', + type=click.Choice(['enabled', 'disabled'])) +@click.pass_context +def llr_interface_local(ctx, interface_name, state): + """Enable/disable LLR local on an interface""" + cfgdb = ctx.obj.cfgdb + _validate_llr_static_mode(cfgdb, interface_name, "local") + cfgdb.mod_entry("LLR_PORT", interface_name, {"llr_local": state}) + + +@llr_interface.command(name='remote') +@click.argument('interface_name', metavar='') +@click.argument('state', metavar='{enabled|disabled}', + type=click.Choice(['enabled', 'disabled'])) +@click.pass_context +def llr_interface_remote(ctx, interface_name, state): + """Enable/disable LLR remote on an interface""" + cfgdb = ctx.obj.cfgdb + _validate_llr_static_mode(cfgdb, interface_name, "remote") + cfgdb.mod_entry("LLR_PORT", interface_name, {"llr_remote": state}) diff --git a/config/main.py b/config/main.py index b304b9a7eac..e6bc834769a 100644 --- a/config/main.py +++ b/config/main.py @@ -70,6 +70,7 @@ from . import dns from . import bgp_cli from . import stp +from . import llr # mock masic APIs for unit test try: @@ -1772,6 +1773,9 @@ def config(ctx): # add stp commands config.add_command(stp.spanning_tree) +# add llr commands +config.add_command(llr.llr) + # add mclag commands config.add_command(mclag.mclag) config.add_command(mclag.mclag_member) diff --git a/counterpoll/main.py b/counterpoll/main.py index cacab1877e5..4c818d7fe75 100755 --- a/counterpoll/main.py +++ b/counterpoll/main.py @@ -3,7 +3,7 @@ from sonic_py_common import device_info, multi_asic from tabulate import tabulate from flow_counter_util.route import exit_if_route_flow_counter_not_support -from swsscommon.swsscommon import ConfigDBConnector, SonicDBConfig +from swsscommon.swsscommon import ConfigDBConnector, SonicDBConfig, SonicV2Connector from swsscommon.swsscommon import CFG_FLEX_COUNTER_TABLE_NAME as CFG_FLEX_COUNTER_TABLE BUFFER_POOL_WATERMARK = "BUFFER_POOL_WATERMARK" @@ -772,6 +772,51 @@ def switch_disable(ctx): ctx.obj.mod_entry(table, key, data) +# LLR counter commands +def _check_llr_capability(): + """Check STATE_DB SWITCH_CAPABILITY|switch for LLR_CAPABLE == 'true'.""" + state_db = SonicV2Connector(host="127.0.0.1") + state_db.connect(state_db.STATE_DB) + val = state_db.get(state_db.STATE_DB, "SWITCH_CAPABILITY|switch", "LLR_CAPABLE") + return val == "true" + + +@cli.group() +@click.option('-n', '--namespace', help='Namespace name', + required=False, + type=click.Choice(get_valid_namespace_choices()), + default=multi_asic.get_current_namespace()) +@click.pass_context +def llr(ctx, namespace): + """ LLR port counter commands """ + if not _check_llr_capability(): + click.echo("Error: LLR is not supported on this platform.") + raise SystemExit(1) + ctx.obj = connect_to_db(namespace) + + +@llr.command(name='interval') +@click.argument('poll_interval', type=click.IntRange(100, 30000)) +@click.pass_context +def llr_interval(ctx, poll_interval): + """ Set LLR port counter query interval """ + ctx.obj.mod_entry(CFG_FLEX_COUNTER_TABLE, "LLR", {"POLL_INTERVAL": poll_interval}) + + +@llr.command(name='enable') +@click.pass_context +def llr_enable(ctx): + """ Enable LLR port counter query """ + ctx.obj.mod_entry(CFG_FLEX_COUNTER_TABLE, "LLR", {"FLEX_COUNTER_STATUS": ENABLE}) + + +@llr.command(name='disable') +@click.pass_context +def llr_disable(ctx): + """ Disable LLR port counter query """ + ctx.obj.mod_entry(CFG_FLEX_COUNTER_TABLE, "LLR", {"FLEX_COUNTER_STATUS": DISABLE}) + + @cli.command() @click.option('-n', '--namespace', help='Namespace name', required=False, @@ -799,6 +844,7 @@ def show(namespace): wred_port_info = configdb.get_entry('FLEX_COUNTER_TABLE', 'WRED_ECN_PORT') srv6_info = configdb.get_entry('FLEX_COUNTER_TABLE', 'SRV6') switch_info = configdb.get_entry('FLEX_COUNTER_TABLE', 'SWITCH') + llr_info = configdb.get_entry('FLEX_COUNTER_TABLE', 'LLR') header = ("Type", "Interval (in ms)", "Status") data = [] @@ -845,6 +891,10 @@ def show(namespace): switch_info.get("POLL_INTERVAL", DEFLT_60_SEC), switch_info.get("FLEX_COUNTER_STATUS", DISABLE) ]) + if llr_info: + data.append(["LLR_STAT", + llr_info.get("POLL_INTERVAL", DEFLT_10_SEC), + llr_info.get("FLEX_COUNTER_STATUS", DISABLE)]) dpu = is_dpu(configdb) if dpu and eni_info: data.append(["ENI_STAT", eni_info.get("POLL_INTERVAL", DEFLT_10_SEC), diff --git a/doc/Command-Reference.md b/doc/Command-Reference.md index 110766c7d42..38aec994db2 100644 --- a/doc/Command-Reference.md +++ b/doc/Command-Reference.md @@ -115,6 +115,10 @@ * [LDAP global config commands](#LDAP-global-config-commands) * [show LDAP server commands](#LDAP-server-show-commands) * [LDAP server config commands](#LDAP-server-config-commands) +* [LLR](#llr) + * [LLR show commands](#llr-show-commands) + * [LLR config commands](#llr-config-commands) + * [LLR clear commands](#llr-clear-commands) * [LLDP](#lldp) * [LLDP show commands](#lldp-show-commands) * [Loading, Reloading And Saving Configuration](#loading-reloading-and-saving-configuration) @@ -8198,6 +8202,224 @@ Since this command might require changing the kernel parameters to specify the a ``` Go Back To [Beginning of the document](#) or [Beginning of this section](#linux-kernel-dump) +## LLR + +This section explains the show, configuration and clear commands for LLR (Link Layer Retry). LLR enables local retransmission of lost frames at the data link layer to reduce reliance on higher-layer recovery. + +All LLR configuration commands check LLR capability in STATE_DB. If the switch does not support LLR, commands are rejected with an error message. + +### LLR show commands + +**show llr interface** + +This command displays LLR interface configuration from APPL_DB. Optionally specify an interface name to filter. + +- Usage: + ``` + show llr interface [] + ``` + +- Example: + ``` + admin@sonic:~$ show llr interface + + LLR Interface Configuration + ---------------------------- + + PORT LLR Mode LLR Local LLR Remote LLR Profile + ---------- -------- ---------- ----------- ------------------------------ + Ethernet0 static enabled enabled llr_800000_40m_profile + Ethernet4 static enabled disabled llr_400000_5m_profile + ``` + +**show llr profile** + +This command displays LLR profile configuration from APPL_DB. Optionally specify a profile name to filter. + +- Usage: + ``` + show llr profile [] + ``` + +- Example: + ``` + admin@sonic:~$ show llr profile llr_800000_40m_profile + +---------------------------------------+--------------+ + | LLR Profile: llr_800000_40m_profile | + +=======================================+==============+ + | Maximum Outstanding Frames | 264 | + +---------------------------------------+--------------+ + | Maximum Outstanding Bytes | 135000 | + +---------------------------------------+--------------+ + | Maximum Replay Count | 3 | + +---------------------------------------+--------------+ + | Maximum Replay Timer(ns) | 5000 | + +---------------------------------------+--------------+ + | PCS Lost Status Timeout(ns) | 50000 | + +---------------------------------------+--------------+ + | Data Age Timeout(ns) | 20000 | + +---------------------------------------+--------------+ + | CTLOS Spacing Bytes | 2048 | + +---------------------------------------+--------------+ + | Init Action | best_effort | + +---------------------------------------+--------------+ + | Flush Action | best_effort | + +---------------------------------------+--------------+ + ``` + +**show llr counters** + +This command displays LLR counter statistics (summary view). Use `-i` to filter by interface. + +- Usage: + ``` + show llr counters [-i ] + ``` + +- Example: + ``` + admin@sonic:~$ show llr counters -i Ethernet0 + + Port Rx STATUS RX_INIT RX_INIT_ECHO RX_ACK RX_NACK RX_OK RX_BAD RX_POISONED RX_REPLAY + --------- ------- ------- ------------ ------ ------- ----- ------ ----------- --------- + Ethernet0 Enable 1 1 15000 0 35000 0 0 0 + + Port Tx STATUS TX_INIT TX_INIT_ECHO TX_ACK TX_NACK TX_OK TX_DISCARD TX_POISONED TX_REPLAY + --------- ------- ------- ------------ ------ ------- ----- ---------- ----------- --------- + Ethernet0 Enable 1 1 15000 0 35000 0 0 0 + ``` + +**show llr counters detailed** + +This command displays detailed LLR counter statistics per port, including all RX/TX counters. Optionally specify an interface name. + +- Usage: + ``` + show llr counters detailed [] + ``` + +- Example: + ``` + admin@sonic:~$ show llr counters detailed Ethernet0 + + LLR Counters - Ethernet0 + ----------------------- + LLR_INIT CtrlOS Transmitted ............................. 1 + LLR_INIT_ECHO CtrlOS Transmitted ............................. 1 + LLR_ACK CtrlOS Transmitted ............................. 35000 + LLR_NACK CtrlOS Transmitted ............................. 0 + + LLR Frames Transmitted OK .................................... 35000 + LLR Frames Transmitted as poisoned ........................... 0 + LLR Frames Discarded at Transmit ............................. 0 + LLR Tx Replay Triggered Count ................................ 0 + + LLR_INIT CtrlOS Received ................................ 1 + LLR_INIT_ECHO CtrlOS Received ................................ 1 + LLR_ACK CtrlOS Received ................................ 15000 + LLR_NACK CtrlOS Received ................................ 0 + LLR_ACK/NACK CtrlOS Received with SeqNum error .............. 0 + + LLR Frames Received OK ....................................... 35000 + LLR Frames Received as Poisoned .............................. 0 + LLR Frames Received as Bad ................................... 0 + LLR Rx Replay Detect Count ................................... 0 + + LLR Frames Received OK with expected seq num ................. 0 + LLR Frames Received Poisoned with expected seq num ........... 0 + LLR Frames Received Bad with expected seq num ................ 0 + + LLR Frames Received with Unexpected seq num .................. 0 + LLR Frames Received with Duplicate seq num ................... 0 + ``` + +Go Back To [Beginning of the document](#) or [Beginning of this section](#llr) + +### LLR config commands + +**config llr interface mode** + +This command configures the LLR mode on a per-port basis. + +- Usage: + ``` + config llr interface mode + ``` + +- Example: + ``` + admin@sonic:~$ sudo config llr interface mode Ethernet0 static + ``` + +**config llr interface local** + +This command enables or disables LLR local (reception) on an interface. Only applicable when the port's LLR mode is `static`. + +- Usage: + ``` + config llr interface local {enabled|disabled} + ``` + +- Example: + ``` + admin@sonic:~$ sudo config llr interface local Ethernet0 enabled + ``` + +**config llr interface remote** + +This command enables or disables LLR remote (transmission) on an interface. Only applicable when the port's LLR mode is `static`. + +- Usage: + ``` + config llr interface remote {enabled|disabled} + ``` + +- Example: + ``` + admin@sonic:~$ sudo config llr interface remote Ethernet0 enabled + ``` + +**counterpoll llr** + +These commands enable, disable, or set the polling interval for LLR counters. + +- Usage: + ``` + counterpoll llr enable + counterpoll llr disable + counterpoll llr interval + ``` + +- Example: + ``` + admin@sonic:~$ sudo counterpoll llr enable + admin@sonic:~$ sudo counterpoll llr interval 1000 + ``` + +Go Back To [Beginning of the document](#) or [Beginning of this section](#llr) + +### LLR clear commands + +**sonic-clear llr counters** + +This command clears LLR counter statistics for all ports or a specific interface. + +- Usage: + ``` + sonic-clear llr counters + sonic-clear llr counters interface + ``` + +- Example: + ``` + admin@sonic:~$ sonic-clear llr counters + LLR counters cleared. + admin@sonic:~$ sonic-clear llr counters interface Ethernet0 + LLR counters cleared for Ethernet0. + ``` + +Go Back To [Beginning of the document](#) or [Beginning of this section](#llr) + ## LLDP ### LLDP show commands diff --git a/scripts/llrstat b/scripts/llrstat new file mode 100755 index 00000000000..b8afad160b3 --- /dev/null +++ b/scripts/llrstat @@ -0,0 +1,352 @@ +#!/usr/bin/env python3 + +##################################################################### +# +# llrstat is a tool for displaying and clearing LLR +# (Link Layer Retry) counter statistics. +# +##################################################################### + +import json +import argparse +import os +import sys + +from collections import OrderedDict +from natsort import natsorted +from tabulate import tabulate + +# mock the redis for unit test purposes # +try: + if os.environ["UTILITIES_UNIT_TESTING"] == "2": + modules_path = os.path.join(os.path.dirname(__file__), "..") + tests_path = os.path.join(modules_path, "tests") + sys.path.insert(0, modules_path) + sys.path.insert(0, tests_path) + import mock_tables.dbconnector +except KeyError: + pass + +from swsscommon.swsscommon import SonicV2Connector +from utilities_common.cli import UserCache + +STATUS_NA = "N/A" + +# +# LLR TX counter list: (SAI stat name, short display name) +# +LLR_TX_COUNTERS = [ + ("SAI_PORT_STAT_LLR_TX_INIT_CTL_OS", "TX_INIT"), + ("SAI_PORT_STAT_LLR_TX_INIT_ECHO_CTL_OS", "TX_INIT_ECHO"), + ("SAI_PORT_STAT_LLR_TX_ACK_CTL_OS", "TX_ACK"), + ("SAI_PORT_STAT_LLR_TX_NACK_CTL_OS", "TX_NACK"), + ("SAI_PORT_STAT_LLR_TX_OK", "TX_OK"), + ("SAI_PORT_STAT_LLR_TX_DISCARD", "TX_DISCARD"), + ("SAI_PORT_STAT_LLR_TX_POISONED", "TX_POISONED"), + ("SAI_PORT_STAT_LLR_TX_REPLAY", "TX_REPLAY"), +] + +# +# LLR RX counter list: (SAI stat name, short display name) +# +LLR_RX_COUNTERS = [ + ("SAI_PORT_STAT_LLR_RX_INIT_CTL_OS", "RX_INIT"), + ("SAI_PORT_STAT_LLR_RX_INIT_ECHO_CTL_OS", "RX_INIT_ECHO"), + ("SAI_PORT_STAT_LLR_RX_ACK_CTL_OS", "RX_ACK"), + ("SAI_PORT_STAT_LLR_RX_NACK_CTL_OS", "RX_NACK"), + ("SAI_PORT_STAT_LLR_RX_ACK_NACK_SEQ_ERROR", "RX_ACK_NACK_SEQ_ERR"), + ("SAI_PORT_STAT_LLR_RX_OK", "RX_OK"), + ("SAI_PORT_STAT_LLR_RX_POISONED", "RX_POISONED"), + ("SAI_PORT_STAT_LLR_RX_BAD", "RX_BAD"), + ("SAI_PORT_STAT_LLR_RX_EXPECTED_SEQ_GOOD", "RX_EXP_SEQ_GOOD"), + ("SAI_PORT_STAT_LLR_RX_EXPECTED_SEQ_POISONED", "RX_EXP_SEQ_POISONED"), + ("SAI_PORT_STAT_LLR_RX_EXPECTED_SEQ_BAD", "RX_EXP_SEQ_BAD"), + ("SAI_PORT_STAT_LLR_RX_MISSING_SEQ", "RX_MISSING_SEQ"), + ("SAI_PORT_STAT_LLR_RX_DUPLICATE_SEQ", "RX_DUPLICATE_SEQ"), + ("SAI_PORT_STAT_LLR_RX_REPLAY", "RX_REPLAY"), +] + +# +# LLR RX summary counters: subset shown by 'show llr counters'. +# The detailed view ('show llr counters detailed') shows all LLR_RX_COUNTERS. +# +LLR_RX_SUMMARY_COUNTERS = [ + ("SAI_PORT_STAT_LLR_RX_INIT_CTL_OS", "RX_INIT"), + ("SAI_PORT_STAT_LLR_RX_INIT_ECHO_CTL_OS", "RX_INIT_ECHO"), + ("SAI_PORT_STAT_LLR_RX_ACK_CTL_OS", "RX_ACK"), + ("SAI_PORT_STAT_LLR_RX_NACK_CTL_OS", "RX_NACK"), + ("SAI_PORT_STAT_LLR_RX_OK", "RX_OK"), + ("SAI_PORT_STAT_LLR_RX_BAD", "RX_BAD"), + ("SAI_PORT_STAT_LLR_RX_POISONED", "RX_POISONED"), + ("SAI_PORT_STAT_LLR_RX_REPLAY", "RX_REPLAY"), +] + +# All SAI counter names (TX + RX) for snapshot/clear operations +LLR_ALL_COUNTERS = [sai for sai, _ in LLR_TX_COUNTERS] + \ + [sai for sai, _ in LLR_RX_COUNTERS] + +COUNTER_TABLE_PREFIX = "COUNTERS:" +COUNTERS_PORT_NAME_MAP = "COUNTERS_PORT_NAME_MAP" +LLR_PORT_TABLE_PREFIX = "LLR_PORT_TABLE:" +LLR_CAPABILITIES_KEY = "PORT_COUNTER_CAPABILITIES|LLR" +LLR_CACHE_APP = "llrstat" + + +class Llrstat(object): + def __init__(self): + self.appl_db = SonicV2Connector(host="127.0.0.1") + self.appl_db.connect(self.appl_db.APPL_DB) + + self.state_db = SonicV2Connector(host="127.0.0.1") + self.state_db.connect(self.state_db.STATE_DB) + + self.counters_db = SonicV2Connector(host="127.0.0.1") + self.counters_db.connect(self.counters_db.COUNTERS_DB) + + self.capabilities = self._get_capabilities() + self.port_name_map = self._get_port_name_map() + self.baseline = self._load_baseline() + + def _load_baseline(self): + cache = UserCache(LLR_CACHE_APP) + cnstat_file = os.path.join(cache.get_directory(), "llrstat") + if os.path.isfile(cnstat_file): + try: + with open(cnstat_file, "r") as f: + return json.load(f) + except (json.JSONDecodeError, IOError): + pass + return {} + + def _get_capabilities(self): + entry = self.state_db.get_all(self.state_db.STATE_DB, LLR_CAPABILITIES_KEY) + return entry if entry else {} + + def _get_port_name_map(self): + return self.counters_db.get_all(self.counters_db.COUNTERS_DB, COUNTERS_PORT_NAME_MAP) + + def _get_llr_ports(self): + keys = self.appl_db.keys(self.appl_db.APPL_DB, "{}*".format(LLR_PORT_TABLE_PREFIX)) + if not keys: + return [] + return natsorted([k.split(":", 1)[1] for k in keys]) + + def _is_supported(self, sai_stat_name): + cap_field = sai_stat_name.replace("SAI_PORT_STAT_", "") + return self.capabilities.get(cap_field, "false") == "true" + + def _get_counter(self, port_oid, sai_stat_name, port_name=None): + if not port_oid: + return STATUS_NA + val = self.counters_db.get( + self.counters_db.COUNTERS_DB, + "{}{}".format(COUNTER_TABLE_PREFIX, port_oid), + sai_stat_name + ) + if val is None: + return STATUS_NA + # Subtract baseline if available + if port_name and port_name in self.baseline: + base_val = self.baseline[port_name].get(sai_stat_name, "0") + try: + val = str(int(val) - int(base_val)) + except (ValueError, TypeError): + pass + return val + + def _get_port_status(self, port): + entry = self.appl_db.get_all( + self.appl_db.APPL_DB, + "{}{}".format(LLR_PORT_TABLE_PREFIX, port) + ) or {} + return entry + + def get_llr_ports(self, interface_name=None): + llr_ports = self._get_llr_ports() + if interface_name: + if interface_name not in llr_ports: + print("Interface {} not found in LLR configuration.".format(interface_name)) + return None + return [interface_name] + return llr_ports + + def show_counters(self, interface_name=None): + if not self.port_name_map: + print("COUNTERS_PORT_NAME_MAP not found.") + return + + llr_ports = self.get_llr_ports(interface_name) + if llr_ports is None: + return + if not llr_ports: + print("No LLR ports configured.") + return + + rx_header = ["Port Rx", "STATUS"] + [name for _, name in LLR_RX_SUMMARY_COUNTERS] + tx_header = ["Port Tx", "STATUS"] + [name for _, name in LLR_TX_COUNTERS] + rx_rows = [] + tx_rows = [] + + for port in llr_ports: + port_oid = self.port_name_map.get(port) + entry = self._get_port_status(port) + rx_status = "Enable" if entry.get("llr_local") == "enabled" else "Disable" + tx_status = "Enable" if entry.get("llr_remote") == "enabled" else "Disable" + + rx_row = [port, rx_status] + for sai_name, _ in LLR_RX_SUMMARY_COUNTERS: + if self._is_supported(sai_name): + rx_row.append(self._get_counter(port_oid, sai_name, port)) + else: + rx_row.append(STATUS_NA) + rx_rows.append(rx_row) + + tx_row = [port, tx_status] + for sai_name, _ in LLR_TX_COUNTERS: + if self._is_supported(sai_name): + tx_row.append(self._get_counter(port_oid, sai_name, port)) + else: + tx_row.append(STATUS_NA) + tx_rows.append(tx_row) + + print(tabulate(rx_rows, headers=rx_header, tablefmt="simple")) + print() + print(tabulate(tx_rows, headers=tx_header, tablefmt="simple")) + print() + + def show_counters_detailed(self, interface_name=None): + if not self.port_name_map: + print("COUNTERS_PORT_NAME_MAP not found.") + return + + llr_ports = self.get_llr_ports(interface_name) + if llr_ports is None: + return + if not llr_ports: + print("No LLR ports configured.") + return + + def get_val(port_oid, sai_name, port_name): + if not self._is_supported(sai_name): + return STATUS_NA + return self._get_counter(port_oid, sai_name, port_name) + + def line(port_oid, port_name, label, sai_name): + val = get_val(port_oid, sai_name, port_name) + dots = "." * max(1, 60 - len(label)) + print("{} {} {}".format(label, dots, val)) + + for port in llr_ports: + port_oid = self.port_name_map.get(port) + print("LLR Counters - {}".format(port)) + print("-" * (16 + len(port))) + + line(port_oid, port, "LLR_INIT CtrlOS Transmitted", "SAI_PORT_STAT_LLR_TX_INIT_CTL_OS") + line(port_oid, port, "LLR_INIT_ECHO CtrlOS Transmitted", "SAI_PORT_STAT_LLR_TX_INIT_ECHO_CTL_OS") + line(port_oid, port, "LLR_ACK CtrlOS Transmitted", "SAI_PORT_STAT_LLR_TX_ACK_CTL_OS") + line(port_oid, port, "LLR_NACK CtrlOS Transmitted", "SAI_PORT_STAT_LLR_TX_NACK_CTL_OS") + print() + line(port_oid, port, "LLR Frames Transmitted OK", "SAI_PORT_STAT_LLR_TX_OK") + line(port_oid, port, "LLR Frames Transmitted as poisoned", "SAI_PORT_STAT_LLR_TX_POISONED") + line(port_oid, port, "LLR Frames Discarded at Transmit", "SAI_PORT_STAT_LLR_TX_DISCARD") + line(port_oid, port, "LLR Tx Replay Triggered Count", "SAI_PORT_STAT_LLR_TX_REPLAY") + print() + line(port_oid, port, "LLR_INIT CtrlOS Received", "SAI_PORT_STAT_LLR_RX_INIT_CTL_OS") + line(port_oid, port, "LLR_INIT_ECHO CtrlOS Received", "SAI_PORT_STAT_LLR_RX_INIT_ECHO_CTL_OS") + line(port_oid, port, "LLR_ACK CtrlOS Received", "SAI_PORT_STAT_LLR_RX_ACK_CTL_OS") + line(port_oid, port, "LLR_NACK CtrlOS Received", "SAI_PORT_STAT_LLR_RX_NACK_CTL_OS") + line(port_oid, port, "LLR_ACK/NACK CtrlOS Received with SeqNum error", "SAI_PORT_STAT_LLR_RX_ACK_NACK_SEQ_ERROR") + print() + line(port_oid, port, "LLR Frames Received OK", "SAI_PORT_STAT_LLR_RX_OK") + line(port_oid, port, "LLR Frames Received as Poisoned", "SAI_PORT_STAT_LLR_RX_POISONED") + line(port_oid, port, "LLR Frames Received as Bad", "SAI_PORT_STAT_LLR_RX_BAD") + line(port_oid, port, "LLR Rx Replay Detect Count", "SAI_PORT_STAT_LLR_RX_REPLAY") + print() + line(port_oid, port, "LLR Frames Received OK with expected seq num", "SAI_PORT_STAT_LLR_RX_EXPECTED_SEQ_GOOD") + line(port_oid, port, "LLR Frames Received Poisoned with expected seq num", "SAI_PORT_STAT_LLR_RX_EXPECTED_SEQ_POISONED") + line(port_oid, port, "LLR Frames Received Bad with expected seq num", "SAI_PORT_STAT_LLR_RX_EXPECTED_SEQ_BAD") + print() + line(port_oid, port, "LLR Frames Received with Unexpected seq num", "SAI_PORT_STAT_LLR_RX_MISSING_SEQ") + line(port_oid, port, "LLR Frames Received with Duplicate seq num", "SAI_PORT_STAT_LLR_RX_DUPLICATE_SEQ") + print() + + def clear_counters(self, interface_name=None): + llr_ports = self.get_llr_ports(interface_name) + if llr_ports is None: + sys.exit(1) + if not llr_ports: + print("No LLR ports configured.") + return + + if not self.port_name_map: + print("COUNTERS_PORT_NAME_MAP not found.") + return + + snapshot = {} + for port in llr_ports: + port_oid = self.port_name_map.get(port) + if not port_oid: + continue + snapshot[port] = {} + for sai_name in LLR_ALL_COUNTERS: + cap_field = sai_name.replace("SAI_PORT_STAT_", "") + if self.capabilities and self.capabilities.get(cap_field, "false") != "true": + continue + val = self.counters_db.get( + self.counters_db.COUNTERS_DB, + "{}{}".format(COUNTER_TABLE_PREFIX, port_oid), + sai_name + ) + snapshot[port][sai_name] = val if val is not None else "0" + + cache = UserCache(LLR_CACHE_APP) + cnstat_file = os.path.join(cache.get_directory(), "llrstat") + with open(cnstat_file, "w") as f: + json.dump(snapshot, f) + + if interface_name: + print("LLR counters cleared for {}.".format(interface_name)) + else: + print("LLR counters cleared.") + + +def main(): + parser = argparse.ArgumentParser( + description='Display or clear LLR counter statistics', + formatter_class=argparse.RawTextHelpFormatter, + epilog=""" +Examples: + llrstat + llrstat -c + llrstat -d + llrstat -i Ethernet0 + llrstat -d -i Ethernet0 + llrstat -c -i Ethernet0 +""" + ) + + parser.add_argument('-c', '--clear', action='store_true', + help='Clear LLR counters (save snapshot)') + parser.add_argument('-d', '--detail', action='store_true', + help='Display detailed per-port counter view') + parser.add_argument('-i', '--interface', default=None, + help='Filter to a specific interface') + parser.add_argument('-v', '--version', action='version', + version='%(prog)s 1.0') + + args = parser.parse_args() + + llrstat = Llrstat() + + if args.clear: + llrstat.clear_counters(args.interface) + sys.exit(0) + + if args.detail: + llrstat.show_counters_detailed(args.interface) + else: + llrstat.show_counters(args.interface) + + +if __name__ == "__main__": + main() diff --git a/setup.py b/setup.py index 535bb409c2d..072ba683b19 100644 --- a/setup.py +++ b/setup.py @@ -145,6 +145,7 @@ 'scripts/ipintutil', 'scripts/lag_keepalive.py', 'scripts/leakageshow', + 'scripts/llrstat', 'scripts/lldpshow', 'scripts/log_ssd_health', 'scripts/mellanox_buffer_migrator.py', diff --git a/show/llr.py b/show/llr.py new file mode 100644 index 00000000000..77bef3639aa --- /dev/null +++ b/show/llr.py @@ -0,0 +1,172 @@ +import click +import utilities_common.cli as clicommon +from natsort import natsorted +from tabulate import tabulate + +LLR_PROFILE_DISPLAY_FIELDS = [ + ("max_outstanding_frames", "Maximum Outstanding Frames"), + ("max_outstanding_bytes", "Maximum Outstanding Bytes"), + ("max_replay_count", "Maximum Replay Count"), + ("max_replay_timer", "Maximum Replay Timer(ns)"), + ("pcs_lost_timeout", "PCS Lost Status Timeout(ns)"), + ("data_age_timeout", "Data Age Timeout(ns)"), + ("ctlos_spacing_bytes", "CTLOS Spacing Bytes"), + ("init_action", "Init Action"), + ("flush_action", "Flush Action"), +] + +STATUS_NA = "N/A" + + +############################################################################## +# 'llr' group ("show llr ...") +############################################################################## + +@click.group(cls=clicommon.AliasedGroup) +@click.pass_context +def llr(ctx): + """Show LLR (Link Layer Retry) information""" + pass + + +############################################################################## +# 'show llr interface [interface-name]' +############################################################################## + +@llr.command(name='interface') +@click.argument('interface_name', metavar='', required=False, default=None) +@clicommon.pass_db +def llr_interface(db, interface_name): + """Show LLR interface configuration""" + conn = db.db + + # Collect APPL_DB entries (operational state) + appl_ports = {} + keys = conn.keys(conn.APPL_DB, "LLR_PORT_TABLE:*") + if keys: + for key in keys: + port = key.split(":", 1)[1] + entry = conn.get_all(conn.APPL_DB, key) + if entry: + appl_ports[port] = entry + + # Collect CONFIG_DB entries not yet in APPL_DB (pending state) + cfg_ports = {} + cfg_keys = conn.keys(conn.CONFIG_DB, "LLR_PORT|*") + if cfg_keys: + for key in cfg_keys: + port = key.split("|", 1)[1] + if port not in appl_ports: + entry = conn.get_all(conn.CONFIG_DB, key) + if entry: + cfg_ports[port] = entry + + if not appl_ports and not cfg_ports: + click.echo("No LLR interface configuration found.") + return + + header = ["PORT", "LLR Mode", "LLR Local", "LLR Remote", "LLR Profile"] + rows = [] + + all_ports = set(appl_ports.keys()) | set(cfg_ports.keys()) + for port in natsorted(all_ports): + if interface_name and port != interface_name: + continue + + if port in appl_ports: + entry = appl_ports[port] + rows.append([ + port, + entry.get("llr_mode", STATUS_NA), + entry.get("llr_local", "disabled"), + entry.get("llr_remote", "disabled"), + entry.get("llr_profile", STATUS_NA), + ]) + else: + entry = cfg_ports[port] + rows.append([ + port, + entry.get("llr_mode", STATUS_NA), + entry.get("llr_local", "disabled"), + entry.get("llr_remote", "disabled"), + "-", + ]) + + if interface_name and not rows: + click.echo("Interface {} not found in LLR configuration.".format(interface_name)) + return + + click.echo() + click.echo("LLR Interface Configuration") + click.echo("----------------------------") + click.echo() + click.echo(tabulate(rows, headers=header, tablefmt="simple")) + click.echo() + + +############################################################################## +# 'show llr profile [profile-name]' +############################################################################## + +@llr.command(name='profile') +@click.argument('profile_name', metavar='', required=False, default=None) +@clicommon.pass_db +def llr_profile(db, profile_name): + """Show LLR profile configuration""" + conn = db.db + + keys = conn.keys(conn.APPL_DB, "LLR_PROFILE_TABLE:*") + if not keys: + click.echo("No LLR profiles found.") + return + + found = False + for key in natsorted(keys): + pname = key.split(":", 1)[1] + if profile_name and pname != profile_name: + continue + + entry = conn.get_all(conn.APPL_DB, key) + if not entry: + continue + + found = True + rows = [[display, entry.get(field, STATUS_NA)] + for field, display in LLR_PROFILE_DISPLAY_FIELDS] + click.echo(tabulate(rows, + headers=["LLR Profile: {}".format(pname), ""], + tablefmt="grid")) + click.echo() + + if profile_name and not found: + click.echo("LLR profile {} not found.".format(profile_name)) + + +############################################################################## +# 'show llr counters [-i interface-name]' +# 'show llr counters detailed [interface-name]' +# +############################################################################## + +@llr.group(name='counters', invoke_without_command=True, cls=clicommon.AliasedGroup) +@click.option('-i', '--interface', 'interface_name', metavar='', + default=None, help='Filter counters for a specific interface') +@click.pass_context +def llr_counters(ctx, interface_name): + """Show LLR counter statistics""" + if ctx.invoked_subcommand is None: + cmd = ['llrstat'] + if interface_name: + cmd += ['-i', str(interface_name)] + clicommon.run_command(cmd) + + +@llr_counters.command(name='detailed') +@click.argument('interface_name', metavar='', required=False, default=None) +@click.pass_context +def llr_counters_detailed(ctx, interface_name): + """Show detailed LLR counter statistics per port""" + cmd = ['llrstat', '-d'] + if interface_name: + cmd += ['-i', str(interface_name)] + clicommon.run_command(cmd) diff --git a/show/main.py b/show/main.py index 4e806bc5bdd..40913f32b2d 100755 --- a/show/main.py +++ b/show/main.py @@ -69,6 +69,7 @@ from . import dns from . import bgp_cli from . import stp +from . import llr from . import srv6 from . import switch from . import icmp @@ -330,6 +331,7 @@ def cli(ctx): cli.add_command(warm_restart.warm_restart) cli.add_command(dns.dns) cli.add_command(stp.spanning_tree) +cli.add_command(llr.llr) cli.add_command(srv6.srv6) cli.add_command(switch.switch) cli.add_command(icmp.icmp) diff --git a/tests/counterpoll_test.py b/tests/counterpoll_test.py index 3bb636789a1..4d3e797ceb0 100644 --- a/tests/counterpoll_test.py +++ b/tests/counterpoll_test.py @@ -36,6 +36,7 @@ WRED_ECN_PORT_STAT 1000 enable SRV6_STAT 10000 enable SWITCH_STAT 60000 enable +LLR_STAT 10000 enable """ expected_counterpoll_show_dpu = """Type Interval (in ms) Status @@ -55,6 +56,7 @@ WRED_ECN_PORT_STAT 1000 enable SRV6_STAT 10000 enable SWITCH_STAT 60000 enable +LLR_STAT 10000 enable ENI_STAT 1000 enable HA_SET_STAT 1000 enable """ diff --git a/tests/llr_test.py b/tests/llr_test.py new file mode 100644 index 00000000000..c70c635ddf0 --- /dev/null +++ b/tests/llr_test.py @@ -0,0 +1,532 @@ +import os +import re +import sys + +test_path = os.path.dirname(os.path.abspath(__file__)) +modules_path = os.path.dirname(test_path) +scripts_path = os.path.join(modules_path, "scripts") +sys.path.insert(0, modules_path) + +import pytest # noqa: E402 +from unittest import mock # noqa: E402 + +from click.testing import CliRunner # noqa: E402 +from utilities_common.db import Db # noqa: E402 +from utilities_common.cli import UserCache # noqa: E402 + +import show.main as show # noqa: E402 +import config.main as config # noqa: E402 +import counterpoll.main as counterpoll # noqa: E402 +import clear.main as clear # noqa: E402 + + +############################################################################### +# Helpers +############################################################################### + +def _make_config_obj(): + """Return a context object matching what config/main.py injects.""" + return Db() + + +def _del_llr_cached_stats(): + """Remove cached llrstat baseline files.""" + cache = UserCache("llrstat") + cache.remove_all() + + +############################################################################### +# show llr interface +############################################################################### + +class TestShowLlrInterface(object): + @classmethod + def setup_class(cls): + os.environ["UTILITIES_UNIT_TESTING"] = "1" + + @classmethod + def teardown_class(cls): + os.environ["UTILITIES_UNIT_TESTING"] = "0" + + def test_show_llr_interface_all(self): + """All interfaces — both Ethernet0/4 from APPL_DB and Ethernet8 from CONFIG_DB.""" + runner = CliRunner() + result = runner.invoke( + show.cli.commands["llr"].commands["interface"], [] + ) + print(result.output) + assert result.exit_code == 0 + assert "Ethernet0" in result.output + assert "Ethernet4" in result.output + assert "Ethernet8" in result.output + assert "static" in result.output + assert "enabled" in result.output + assert "disabled" in result.output + assert "llr_800000_40m_profile" in result.output + assert "llr_400000_5m_profile" in result.output + + def test_show_llr_interface_specific(self): + """Single port filter — only Ethernet0 rows present.""" + runner = CliRunner() + result = runner.invoke( + show.cli.commands["llr"].commands["interface"], ["Ethernet0"] + ) + print(result.output) + assert result.exit_code == 0 + assert "Ethernet0" in result.output + assert "Ethernet4" not in result.output + assert "llr_800000_40m_profile" in result.output + + def test_show_llr_interface_config_db_only(self): + """Port in CONFIG_DB but not APPL_DB — profile shows as dash.""" + runner = CliRunner() + result = runner.invoke( + show.cli.commands["llr"].commands["interface"], ["Ethernet8"] + ) + print(result.output) + assert result.exit_code == 0 + assert "Ethernet8" in result.output + assert "static" in result.output + # Profile column should show "-" for CONFIG_DB-only entries + eth8_lines = [line for line in result.output.split('\n') if 'Ethernet8' in line] + assert any('-' in line for line in eth8_lines) + + def test_show_llr_interface_invalid(self): + """Invalid interface — error message, no traceback.""" + runner = CliRunner() + result = runner.invoke( + show.cli.commands["llr"].commands["interface"], ["EthernetXXX"] + ) + print(result.output) + assert result.exit_code == 0 + assert "not found" in result.output + assert "EthernetXXX" in result.output + + +############################################################################### +# show llr profile +############################################################################### + +class TestShowLlrProfile(object): + @classmethod + def setup_class(cls): + os.environ["UTILITIES_UNIT_TESTING"] = "1" + + @classmethod + def teardown_class(cls): + os.environ["UTILITIES_UNIT_TESTING"] = "0" + + def test_show_llr_profile_all(self): + """Both profiles must appear with key attributes.""" + runner = CliRunner() + result = runner.invoke( + show.cli.commands["llr"].commands["profile"], [] + ) + print(result.output) + assert result.exit_code == 0 + assert "llr_800000_40m_profile" in result.output + assert "llr_400000_5m_profile" in result.output + assert "264" in result.output # max_outstanding_frames for 800G profile + assert "115" in result.output # max_outstanding_frames for 400G profile + assert "135000" in result.output # max_outstanding_bytes for 800G + assert "best_effort" in result.output + + def test_show_llr_profile_specific(self): + """Single profile filter — only 800G profile rows.""" + runner = CliRunner() + result = runner.invoke( + show.cli.commands["llr"].commands["profile"], + ["llr_800000_40m_profile"] + ) + print(result.output) + assert result.exit_code == 0 + assert "llr_800000_40m_profile" in result.output + assert "llr_400000_5m_profile" not in result.output + assert "264" in result.output + + def test_show_llr_profile_invalid(self): + """Nonexistent profile — error message.""" + runner = CliRunner() + result = runner.invoke( + show.cli.commands["llr"].commands["profile"], + ["nonexistent_profile"] + ) + print(result.output) + assert result.exit_code == 0 + assert "not found" in result.output + + +############################################################################### +# show llr counters +############################################################################### + +class TestShowLlrCounters(object): + @classmethod + def setup_class(cls): + cls._original_path = os.environ.get("PATH", "") + if scripts_path not in cls._original_path.split(os.pathsep): + os.environ["PATH"] = cls._original_path + os.pathsep + scripts_path + os.environ["UTILITIES_UNIT_TESTING"] = "2" + _del_llr_cached_stats() + + @classmethod + def teardown_class(cls): + os.environ["PATH"] = cls._original_path + os.environ["UTILITIES_UNIT_TESTING"] = "0" + + def test_show_llr_counters_all(self): + """Counter summary for all configured ports.""" + runner = CliRunner() + result = runner.invoke( + show.cli.commands["llr"].commands["counters"], [] + ) + print(result.output) + assert result.exit_code == 0 + # Both ports present in output + assert "Ethernet0" in result.output + assert "Ethernet4" in result.output + # Known TX counter values for Ethernet0 — scoped to Ethernet0 rows + eth0_lines = [line for line in result.output.split('\n') if 'Ethernet0' in line] + assert any('15000' in line for line in eth0_lines), "15000 not found in Ethernet0 row" + assert any('35000' in line for line in eth0_lines), "35000 not found in Ethernet0 row" + # Unsupported RX counters shown as N/A + assert "N/A" in result.output + + def test_show_llr_counters_interface(self): + """Counter summary filtered to a single port via --interface.""" + runner = CliRunner() + result = runner.invoke( + show.cli.commands["llr"].commands["counters"], + ["--interface", "Ethernet0"] + ) + print(result.output) + assert result.exit_code == 0 + assert "Ethernet0" in result.output + assert "Ethernet4" not in result.output + assert "N/A" in result.output + + def test_show_llr_counters_invalid_interface(self): + """Counter summary for a port not in LLR config — error message.""" + runner = CliRunner() + result = runner.invoke( + show.cli.commands["llr"].commands["counters"], + ["--interface", "EthernetXXX"] + ) + print(result.output) + assert "not found" in result.output + + +############################################################################### +# show llr counters detailed +############################################################################### + +class TestShowLlrCountersDetailed(object): + @classmethod + def setup_class(cls): + cls._original_path = os.environ.get("PATH", "") + if scripts_path not in cls._original_path.split(os.pathsep): + os.environ["PATH"] = cls._original_path + os.pathsep + scripts_path + os.environ["UTILITIES_UNIT_TESTING"] = "2" + _del_llr_cached_stats() + + @classmethod + def teardown_class(cls): + os.environ["PATH"] = cls._original_path + os.environ["UTILITIES_UNIT_TESTING"] = "0" + + def test_show_llr_counters_detailed_all(self): + """Detailed counters for all ports.""" + runner = CliRunner() + result = runner.invoke( + show.cli.commands["llr"].commands["counters"].commands["detailed"], [] + ) + print(result.output) + assert result.exit_code == 0 + assert "Ethernet0" in result.output + assert "Ethernet4" in result.output + # A few label strings that appear in detailed output + assert "LLR_INIT" in result.output + assert "N/A" in result.output # unsupported counters + + def test_show_llr_counters_detailed_interface(self): + """Detailed counters for a specific port.""" + runner = CliRunner() + result = runner.invoke( + show.cli.commands["llr"].commands["counters"].commands["detailed"], + ["Ethernet0"] + ) + print(result.output) + assert result.exit_code == 0 + assert "LLR Counters - Ethernet0" in result.output + assert "Ethernet4" not in result.output + # TX counters are all supported → values appear in the Ethernet0 section. + # Only Ethernet0 is displayed so any line containing these values is in scope. + assert re.search(r'\b15000\b', result.output), "15000 not found in detailed output" + assert re.search(r'\b35000\b', result.output), "35000 not found in detailed output" + # RX counters beyond first 4 are not supported → N/A + assert "N/A" in result.output + + def test_show_llr_counters_detailed_invalid_interface(self): + """Detailed counters for unknown port — error message.""" + runner = CliRunner() + result = runner.invoke( + show.cli.commands["llr"].commands["counters"].commands["detailed"], + ["EthernetXXX"] + ) + print(result.output) + assert "not found" in result.output + + +############################################################################### +# config llr interface +############################################################################### + +class TestConfigLlrInterface(object): + @classmethod + def setup_class(cls): + os.environ["UTILITIES_UNIT_TESTING"] = "1" + + @classmethod + def teardown_class(cls): + os.environ["UTILITIES_UNIT_TESTING"] = "0" + + @pytest.mark.parametrize("state", ["enabled", "disabled"]) + def test_config_llr_interface_local(self, state): + """config llr interface local {enabled|disabled}.""" + runner = CliRunner() + obj = _make_config_obj() + result = runner.invoke( + config.config.commands["llr"].commands["interface"].commands["local"], + ["Ethernet0", state], + obj=obj + ) + print(result.output) + assert result.exit_code == 0 + table = obj.cfgdb.get_table("LLR_PORT") + assert table.get("Ethernet0", {}).get("llr_local") == state + + @pytest.mark.parametrize("state", ["enabled", "disabled"]) + def test_config_llr_interface_remote(self, state): + """config llr interface remote {enabled|disabled}.""" + runner = CliRunner() + obj = _make_config_obj() + result = runner.invoke( + config.config.commands["llr"].commands["interface"].commands["remote"], + ["Ethernet0", state], + obj=obj + ) + print(result.output) + assert result.exit_code == 0 + table = obj.cfgdb.get_table("LLR_PORT") + assert table.get("Ethernet0", {}).get("llr_remote") == state + + def test_config_llr_interface_mode(self): + """config llr interface mode static.""" + runner = CliRunner() + obj = _make_config_obj() + result = runner.invoke( + config.config.commands["llr"].commands["interface"].commands["mode"], + ["Ethernet0", "static"], + obj=obj + ) + print(result.output) + assert result.exit_code == 0 + table = obj.cfgdb.get_table("LLR_PORT") + assert table.get("Ethernet0", {}).get("llr_mode") == "static" + + def test_config_llr_no_capability(self): + """Capability absent - command must error with 'not supported'.""" + runner = CliRunner() + obj = _make_config_obj() + with mock.patch("config.llr._check_llr_capability", return_value=False): + result = runner.invoke( + config.config.commands["llr"].commands["interface"].commands["mode"], + ["Ethernet0", "static"], + obj=obj + ) + print(result.output) + assert result.exit_code != 0 + assert "not supported" in result.output + + def test_config_llr_invalid_interface(self): + """Invalid port - command must error with 'does not exist'.""" + runner = CliRunner() + obj = _make_config_obj() + result = runner.invoke( + config.config.commands["llr"].commands["interface"].commands["mode"], + ["EthernetXXX", "static"], + obj=obj + ) + print(result.output) + assert result.exit_code != 0 + assert "does not exist" in result.output + + def test_config_llr_local_remote_mode_validation(self): + """local/remote: allowed when mode absent (YANG default static), + rejected when mode is non-static.""" + runner = CliRunner() + + # --- mode absent → defaults to static, should succeed --- + obj = _make_config_obj() + obj.cfgdb.mod_entry("LLR_PORT", "Ethernet0", None) + for subcmd in ("local", "remote"): + result = runner.invoke( + config.config.commands["llr"].commands["interface"].commands[subcmd], + ["Ethernet0", "enabled"], + obj=obj + ) + print(result.output) + assert result.exit_code == 0, \ + "{} should succeed when llr_mode is absent".format(subcmd) + + # --- mode explicitly non-static → should error --- + obj = _make_config_obj() + obj.cfgdb.mod_entry("LLR_PORT", "Ethernet0", + {"llr_mode": "dynamic"}) + for subcmd in ("local", "remote"): + result = runner.invoke( + config.config.commands["llr"].commands["interface"].commands[subcmd], + ["Ethernet0", "enabled"], + obj=obj + ) + print(result.output) + assert result.exit_code != 0, \ + "{} should fail when llr_mode is dynamic".format(subcmd) + assert "only applicable" in result.output + + +############################################################################### +# counterpoll llr +############################################################################### + +class TestCounterpollLlr(object): + @classmethod + def setup_class(cls): + os.environ["UTILITIES_UNIT_TESTING"] = "1" + + @classmethod + def teardown_class(cls): + os.environ["UTILITIES_UNIT_TESTING"] = "0" + + @pytest.mark.parametrize("status", ["enable", "disable"]) + def test_llr_counter_status(self, status): + """counterpoll llr {enable|disable} updates FLEX_COUNTER_STATUS.""" + runner = CliRunner() + db = Db() + result = runner.invoke( + counterpoll.cli.commands["llr"].commands[status], [], + obj=db.cfgdb + ) + print(result.output) + assert result.exit_code == 0 + table = db.cfgdb.get_table("FLEX_COUNTER_TABLE") + assert table.get("LLR", {}).get("FLEX_COUNTER_STATUS") == status + + def test_llr_counter_interval_valid(self): + """counterpoll llr interval 10000 — accepted within range.""" + runner = CliRunner() + db = Db() + result = runner.invoke( + counterpoll.cli.commands["llr"].commands["interval"], + ["10000"], + obj=db.cfgdb + ) + print(result.output) + assert result.exit_code == 0 + table = db.cfgdb.get_table("FLEX_COUNTER_TABLE") + assert table.get("LLR", {}).get("POLL_INTERVAL") == "10000" + + def test_llr_counter_interval_too_low(self): + """counterpoll llr interval 50 — rejected (below 100 ms minimum).""" + runner = CliRunner() + db = Db() + result = runner.invoke( + counterpoll.cli.commands["llr"].commands["interval"], + ["50"], + obj=db.cfgdb + ) + print(result.output) + assert result.exit_code == 2 + + def test_llr_counter_interval_too_high(self): + """counterpoll llr interval 40000 — rejected (above 30000 ms maximum).""" + runner = CliRunner() + db = Db() + result = runner.invoke( + counterpoll.cli.commands["llr"].commands["interval"], + ["40000"], + obj=db.cfgdb + ) + print(result.output) + assert result.exit_code == 2 + + def test_counterpoll_show_includes_llr(self): + """counterpoll show lists LLR_STAT with configured interval.""" + runner = CliRunner() + result = runner.invoke(counterpoll.cli.commands["show"], []) + print(result.output) + assert result.exit_code == 0 + assert "LLR_STAT" in result.output + assert re.search(r'LLR_STAT.*10000', result.output), "LLR_STAT row does not contain 10000" + + def test_counterpoll_llr_no_capability(self): + """counterpoll llr enable rejects when LLR_CAPABLE is not true.""" + runner = CliRunner() + with mock.patch("counterpoll.main._check_llr_capability", return_value=False): + result = runner.invoke( + counterpoll.cli.commands["llr"], ["enable"] + ) + print(result.output) + assert result.exit_code != 0 + assert "not supported" in result.output + + +############################################################################### +# sonic-clear llr counters +############################################################################### + +class TestClearLlrCounters(object): + @classmethod + def setup_class(cls): + cls._original_path = os.environ.get("PATH", "") + if scripts_path not in cls._original_path.split(os.pathsep): + os.environ["PATH"] = cls._original_path + os.pathsep + scripts_path + os.environ["UTILITIES_UNIT_TESTING"] = "2" + _del_llr_cached_stats() + + @classmethod + def teardown_class(cls): + os.environ["PATH"] = cls._original_path + os.environ["UTILITIES_UNIT_TESTING"] = "0" + _del_llr_cached_stats() + + def test_clear_llr_counters_all(self): + """sonic-clear llr counters — clear all ports.""" + runner = CliRunner() + result = runner.invoke( + clear.cli.commands["llr"].commands["counters"], [] + ) + print(result.output) + assert result.exit_code == 0 + assert "LLR counters cleared" in result.output + + def test_clear_llr_counters_interface(self): + """sonic-clear llr counters interface Ethernet0 — clear single port.""" + runner = CliRunner() + result = runner.invoke( + clear.cli.commands["llr"].commands["counters"].commands["interface"], + ["Ethernet0"] + ) + print(result.output) + assert result.exit_code == 0 + assert "LLR counters cleared for Ethernet0" in result.output + + def test_clear_llr_counters_interface_invalid(self): + """sonic-clear llr counters interface EthernetXXX — error for unknown port.""" + runner = CliRunner() + result = runner.invoke( + clear.cli.commands["llr"].commands["counters"].commands["interface"], + ["EthernetXXX"] + ) + print(result.output) + assert "not found" in result.output diff --git a/tests/mock_tables/appl_db.json b/tests/mock_tables/appl_db.json index 06a64ff9b2d..45860b46f29 100644 --- a/tests/mock_tables/appl_db.json +++ b/tests/mock_tables/appl_db.json @@ -453,5 +453,39 @@ "INNER_SRC_MAC": "f4:93:9f:ef:c4:7e", "REDIRECT_ACTION": "10.0.0.75", "TUNNEL_VNI": "4321" + }, + "LLR_PORT_TABLE:Ethernet0": { + "llr_mode": "static", + "llr_local": "enabled", + "llr_remote": "enabled", + "llr_profile": "llr_800000_40m_profile" + }, + "LLR_PORT_TABLE:Ethernet4": { + "llr_mode": "static", + "llr_local": "enabled", + "llr_remote": "disabled", + "llr_profile": "llr_400000_5m_profile" + }, + "LLR_PROFILE_TABLE:llr_800000_40m_profile": { + "max_outstanding_frames": "264", + "max_outstanding_bytes": "135000", + "max_replay_count": "3", + "max_replay_timer": "5000", + "pcs_lost_timeout": "50000", + "data_age_timeout": "20000", + "ctlos_spacing_bytes": "2048", + "init_action": "best_effort", + "flush_action": "best_effort" + }, + "LLR_PROFILE_TABLE:llr_400000_5m_profile": { + "max_outstanding_frames": "115", + "max_outstanding_bytes": "58768", + "max_replay_count": "3", + "max_replay_timer": "5000", + "pcs_lost_timeout": "50000", + "data_age_timeout": "20000", + "ctlos_spacing_bytes": "2048", + "init_action": "best_effort", + "flush_action": "best_effort" } } diff --git a/tests/mock_tables/config_db.json b/tests/mock_tables/config_db.json index 255801d523b..f93d657ccf7 100644 --- a/tests/mock_tables/config_db.json +++ b/tests/mock_tables/config_db.json @@ -2283,6 +2283,27 @@ "POLL_INTERVAL": "60000", "FLEX_COUNTER_STATUS": "enable" }, + "FLEX_COUNTER_TABLE|LLR": { + "POLL_INTERVAL": "10000", + "FLEX_COUNTER_STATUS": "enable" + }, + "LLR_PORT|Ethernet0": { + "llr_mode": "static", + "llr_local": "enabled", + "llr_remote": "enabled", + "llr_profile": "llr_800000_40m_profile" + }, + "LLR_PORT|Ethernet4": { + "llr_mode": "static", + "llr_local": "enabled", + "llr_remote": "disabled", + "llr_profile": "llr_400000_5m_profile" + }, + "LLR_PORT|Ethernet8": { + "llr_mode": "static", + "llr_local": "enabled", + "llr_remote": "disabled" + }, "PFC_WD|Ethernet0": { "action": "drop", "detection_time": "600", diff --git a/tests/mock_tables/counters_db.json b/tests/mock_tables/counters_db.json index f7bd4faef63..867f73bbf29 100644 --- a/tests/mock_tables/counters_db.json +++ b/tests/mock_tables/counters_db.json @@ -1278,7 +1278,29 @@ "SAI_PORT_STAT_IF_IN_FEC_CODEWORD_ERRORS_S15": "0", "SAI_PORT_STAT_TRIM_PACKETS": "100", "SAI_PORT_STAT_DROPPED_TRIM_PACKETS": "50", - "SAI_PORT_STAT_TX_TRIM_PACKETS": "50" + "SAI_PORT_STAT_TX_TRIM_PACKETS": "50", + "SAI_PORT_STAT_LLR_TX_INIT_CTL_OS": "1", + "SAI_PORT_STAT_LLR_TX_INIT_ECHO_CTL_OS": "1", + "SAI_PORT_STAT_LLR_TX_ACK_CTL_OS": "15000", + "SAI_PORT_STAT_LLR_TX_NACK_CTL_OS": "0", + "SAI_PORT_STAT_LLR_TX_OK": "35000", + "SAI_PORT_STAT_LLR_TX_DISCARD": "0", + "SAI_PORT_STAT_LLR_TX_POISONED": "0", + "SAI_PORT_STAT_LLR_TX_REPLAY": "5", + "SAI_PORT_STAT_LLR_RX_INIT_CTL_OS": "1", + "SAI_PORT_STAT_LLR_RX_INIT_ECHO_CTL_OS": "1", + "SAI_PORT_STAT_LLR_RX_ACK_CTL_OS": "15000", + "SAI_PORT_STAT_LLR_RX_NACK_CTL_OS": "0", + "SAI_PORT_STAT_LLR_RX_ACK_NACK_SEQ_ERROR": "0", + "SAI_PORT_STAT_LLR_RX_OK": "35000", + "SAI_PORT_STAT_LLR_RX_POISONED": "0", + "SAI_PORT_STAT_LLR_RX_BAD": "0", + "SAI_PORT_STAT_LLR_RX_EXPECTED_SEQ_GOOD": "35000", + "SAI_PORT_STAT_LLR_RX_EXPECTED_SEQ_POISONED": "0", + "SAI_PORT_STAT_LLR_RX_EXPECTED_SEQ_BAD": "0", + "SAI_PORT_STAT_LLR_RX_MISSING_SEQ": "0", + "SAI_PORT_STAT_LLR_RX_DUPLICATE_SEQ": "0", + "SAI_PORT_STAT_LLR_RX_REPLAY": "5" }, "COUNTERS:oid:0x1000000000013": { "SAI_PORT_STAT_IF_IN_UCAST_PKTS": "4", @@ -1377,7 +1399,29 @@ "SAI_PORT_STAT_WRED_DROPPED_PACKETS":"101", "SAI_PORT_STAT_TRIM_PACKETS": "20000", "SAI_PORT_STAT_DROPPED_TRIM_PACKETS": "10000", - "SAI_PORT_STAT_TX_TRIM_PACKETS": "10000" + "SAI_PORT_STAT_TX_TRIM_PACKETS": "10000", + "SAI_PORT_STAT_LLR_TX_INIT_CTL_OS": "2", + "SAI_PORT_STAT_LLR_TX_INIT_ECHO_CTL_OS": "2", + "SAI_PORT_STAT_LLR_TX_ACK_CTL_OS": "8000", + "SAI_PORT_STAT_LLR_TX_NACK_CTL_OS": "1", + "SAI_PORT_STAT_LLR_TX_OK": "20000", + "SAI_PORT_STAT_LLR_TX_DISCARD": "1", + "SAI_PORT_STAT_LLR_TX_POISONED": "0", + "SAI_PORT_STAT_LLR_TX_REPLAY": "10", + "SAI_PORT_STAT_LLR_RX_INIT_CTL_OS": "2", + "SAI_PORT_STAT_LLR_RX_INIT_ECHO_CTL_OS": "2", + "SAI_PORT_STAT_LLR_RX_ACK_CTL_OS": "8000", + "SAI_PORT_STAT_LLR_RX_NACK_CTL_OS": "1", + "SAI_PORT_STAT_LLR_RX_ACK_NACK_SEQ_ERROR": "1", + "SAI_PORT_STAT_LLR_RX_OK": "20000", + "SAI_PORT_STAT_LLR_RX_POISONED": "0", + "SAI_PORT_STAT_LLR_RX_BAD": "2", + "SAI_PORT_STAT_LLR_RX_EXPECTED_SEQ_GOOD": "20000", + "SAI_PORT_STAT_LLR_RX_EXPECTED_SEQ_POISONED": "0", + "SAI_PORT_STAT_LLR_RX_EXPECTED_SEQ_BAD": "2", + "SAI_PORT_STAT_LLR_RX_MISSING_SEQ": "1", + "SAI_PORT_STAT_LLR_RX_DUPLICATE_SEQ": "0", + "SAI_PORT_STAT_LLR_RX_REPLAY": "10" }, "COUNTERS:oid:0x1000000000014": { "SAI_PORT_STAT_IF_IN_UCAST_PKTS": "6", diff --git a/tests/mock_tables/state_db.json b/tests/mock_tables/state_db.json index e7888f7873b..f4b6c3cc451 100644 --- a/tests/mock_tables/state_db.json +++ b/tests/mock_tables/state_db.json @@ -1068,7 +1068,23 @@ "MIRRORV6": "true", "PORT_TPID_CAPABLE": "true", "LAG_TPID_CAPABLE": "true", - "ACL_ACTION|PACKET_ACTION": "FORWARD" + "ACL_ACTION|PACKET_ACTION": "FORWARD", + "LLR_CAPABLE": "true", + "LLR_SUPPORTED_PROFILE_ATTRIBUTES": "OUTSTANDING_BYTES_MAX,OUTSTANDING_FRAMES_MAX,REPLAY_TIMER_MAX,REPLAY_COUNT_MAX,PCS_LOST_TIMEOUT,DATA_AGE_TIMEOUT,CTLOS_TARGET_SPACING" + }, + "PORT_COUNTER_CAPABILITIES|LLR": { + "LLR_TX_INIT_CTL_OS": "true", + "LLR_TX_INIT_ECHO_CTL_OS": "true", + "LLR_TX_ACK_CTL_OS": "true", + "LLR_TX_NACK_CTL_OS": "true", + "LLR_TX_OK": "true", + "LLR_TX_DISCARD": "true", + "LLR_TX_POISONED": "true", + "LLR_TX_REPLAY": "true", + "LLR_RX_INIT_CTL_OS": "true", + "LLR_RX_INIT_ECHO_CTL_OS": "true", + "LLR_RX_ACK_CTL_OS": "true", + "LLR_RX_NACK_CTL_OS": "true" }, "ACL_STAGE_CAPABILITY_TABLE|INGRESS": { "action_list": "PACKET_ACTION,REDIRECT_ACTION,MIRROR_INGRESS_ACTION"