From 452421241be8d4325a8382507bbde9025d770de7 Mon Sep 17 00:00:00 2001 From: Dawei Huang Date: Fri, 6 Feb 2026 10:43:31 -0600 Subject: [PATCH 01/43] Fix TLS certificate clock skew issue in gNOI tests (#22248) Replace shell-based openssl certificate generation with Python cryptography library. Certificates are now backdated by 1 day to handle clock skew between test host, DUT, and PTF container. This fixes the error: "tls: failed to verify certificate: x509: certificate has expired or is not yet valid: current time ... is before ..." Changes: - Add tests/common/cert_utils.py with TlsCertificateGenerator class - Add create_gnmi_cert_generator() factory for gNMI/gNOI naming - Update grpc_fixtures.py to use Python-based cert generation - Remove localhost fixture dependency from setup_gnoi_tls_server Signed-off-by: Dawei Huang --- tests/common/cert_utils.py | 389 +++++++++++++++++++++++++ tests/common/fixtures/grpc_fixtures.py | 83 ++---- 2 files changed, 420 insertions(+), 52 deletions(-) create mode 100644 tests/common/cert_utils.py diff --git a/tests/common/cert_utils.py b/tests/common/cert_utils.py new file mode 100644 index 00000000000..cc50eb1d5bd --- /dev/null +++ b/tests/common/cert_utils.py @@ -0,0 +1,389 @@ +""" +Certificate generation utilities for TLS testing. + +This module provides Python-native certificate generation using the cryptography +library, replacing shell-based openssl commands for better control and reliability. + +Can be used for any TLS-based service testing: gNOI, gNMI, REST API, etc. +""" +import os +import ipaddress +from datetime import datetime, timedelta, timezone +from typing import Optional, Tuple, List + +from cryptography import x509 +from cryptography.x509.oid import NameOID +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa + + +class TlsCertificateGenerator: + """ + Generate TLS certificates for testing. + + This class generates a complete certificate chain (CA, server, client) with + configurable validity periods and Subject Alternative Names. By default, + certificates are backdated by 1 day to handle clock skew between test hosts. + + Can be used for any TLS-based service: gNOI, gNMI, REST API, etc. + + Attributes: + server_ip: IP address to include in server certificate SAN + validity_days: Number of days certificates are valid (default: 825) + backdate_days: Days to backdate not_valid_before (default: 1) + + Example: + generator = TlsCertificateGenerator(server_ip="10.0.0.1") + generator.write_all("/tmp/certs") + # Creates: ca.crt, ca.key, server.crt, server.key, client.crt, client.key + """ + + # Default certificate file names + DEFAULT_CA_CERT = "ca.crt" + DEFAULT_CA_KEY = "ca.key" + DEFAULT_SERVER_CERT = "server.crt" + DEFAULT_SERVER_KEY = "server.key" + DEFAULT_CLIENT_CERT = "client.crt" + DEFAULT_CLIENT_KEY = "client.key" + + # Default certificate subjects + DEFAULT_CA_CN = "test.ca.sonic" + DEFAULT_SERVER_CN = "test.server.sonic" + DEFAULT_CLIENT_CN = "test.client.sonic" + + def __init__( + self, + server_ip: str, + validity_days: int = 825, + backdate_days: int = 1, + dns_names: Optional[List[str]] = None, + key_size: int = 2048, + ca_cn: Optional[str] = None, + server_cn: Optional[str] = None, + client_cn: Optional[str] = None, + ca_cert_name: Optional[str] = None, + ca_key_name: Optional[str] = None, + server_cert_name: Optional[str] = None, + server_key_name: Optional[str] = None, + client_cert_name: Optional[str] = None, + client_key_name: Optional[str] = None, + ): + """ + Initialize the certificate generator. + + Args: + server_ip: IP address to include in server certificate SAN + validity_days: Certificate validity period in days + backdate_days: Days to backdate not_valid_before to handle clock skew + dns_names: List of DNS names to include in server certificate SAN + key_size: RSA key size in bits + ca_cn: Common Name for CA certificate + server_cn: Common Name for server certificate + client_cn: Common Name for client certificate + ca_cert_name: Filename for CA certificate + ca_key_name: Filename for CA private key + server_cert_name: Filename for server certificate + server_key_name: Filename for server private key + client_cert_name: Filename for client certificate + client_key_name: Filename for client private key + """ + self.server_ip = server_ip + self.validity_days = validity_days + self.backdate_days = backdate_days + self.dns_names = dns_names or ["localhost"] + self.key_size = key_size + + # Certificate subject names (configurable) + self.ca_cn = ca_cn or self.DEFAULT_CA_CN + self.server_cn = server_cn or self.DEFAULT_SERVER_CN + self.client_cn = client_cn or self.DEFAULT_CLIENT_CN + + # Certificate file names (configurable) + self.ca_cert_name = ca_cert_name or self.DEFAULT_CA_CERT + self.ca_key_name = ca_key_name or self.DEFAULT_CA_KEY + self.server_cert_name = server_cert_name or self.DEFAULT_SERVER_CERT + self.server_key_name = server_key_name or self.DEFAULT_SERVER_KEY + self.client_cert_name = client_cert_name or self.DEFAULT_CLIENT_CERT + self.client_key_name = client_key_name or self.DEFAULT_CLIENT_KEY + + # Generated keys and certificates (populated by generate_all) + self._ca_key: Optional[rsa.RSAPrivateKey] = None + self._ca_cert: Optional[x509.Certificate] = None + self._server_key: Optional[rsa.RSAPrivateKey] = None + self._server_cert: Optional[x509.Certificate] = None + self._client_key: Optional[rsa.RSAPrivateKey] = None + self._client_cert: Optional[x509.Certificate] = None + + def _generate_key(self) -> rsa.RSAPrivateKey: + """Generate an RSA private key.""" + return rsa.generate_private_key( + public_exponent=65537, + key_size=self.key_size, + ) + + def _get_validity_period(self) -> Tuple[datetime, datetime]: + """Get certificate validity period with backdating for clock skew tolerance.""" + now = datetime.now(timezone.utc) + not_valid_before = now - timedelta(days=self.backdate_days) + not_valid_after = now + timedelta(days=self.validity_days) + return not_valid_before, not_valid_after + + def _generate_ca(self) -> Tuple[rsa.RSAPrivateKey, x509.Certificate]: + """Generate CA certificate and key.""" + key = self._generate_key() + not_valid_before, not_valid_after = self._get_validity_period() + + subject = issuer = x509.Name([ + x509.NameAttribute(NameOID.COMMON_NAME, self.ca_cn), + ]) + + cert = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(issuer) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(not_valid_before) + .not_valid_after(not_valid_after) + .add_extension( + x509.BasicConstraints(ca=True, path_length=None), + critical=True, + ) + .add_extension( + x509.KeyUsage( + digital_signature=True, + key_cert_sign=True, + crl_sign=True, + key_encipherment=False, + content_commitment=False, + data_encipherment=False, + key_agreement=False, + encipher_only=False, + decipher_only=False, + ), + critical=True, + ) + .sign(key, hashes.SHA256()) + ) + + return key, cert + + def _generate_server( + self, ca_key: rsa.RSAPrivateKey, ca_cert: x509.Certificate + ) -> Tuple[rsa.RSAPrivateKey, x509.Certificate]: + """Generate server certificate signed by CA.""" + key = self._generate_key() + not_valid_before, not_valid_after = self._get_validity_period() + + subject = x509.Name([ + x509.NameAttribute(NameOID.COMMON_NAME, self.server_cn), + ]) + + # Build SAN with DNS names and server IP address + san_entries = [x509.DNSName(name) for name in self.dns_names] + san_entries.append(x509.IPAddress(ipaddress.ip_address(self.server_ip))) + + cert = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(ca_cert.subject) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(not_valid_before) + .not_valid_after(not_valid_after) + .add_extension( + x509.SubjectAlternativeName(san_entries), + critical=False, + ) + .add_extension( + x509.BasicConstraints(ca=False, path_length=None), + critical=True, + ) + .add_extension( + x509.KeyUsage( + digital_signature=True, + key_encipherment=True, + key_cert_sign=False, + crl_sign=False, + content_commitment=False, + data_encipherment=False, + key_agreement=False, + encipher_only=False, + decipher_only=False, + ), + critical=True, + ) + .add_extension( + x509.ExtendedKeyUsage([x509.oid.ExtendedKeyUsageOID.SERVER_AUTH]), + critical=False, + ) + .sign(ca_key, hashes.SHA256()) + ) + + return key, cert + + def _generate_client( + self, ca_key: rsa.RSAPrivateKey, ca_cert: x509.Certificate + ) -> Tuple[rsa.RSAPrivateKey, x509.Certificate]: + """Generate client certificate signed by CA.""" + key = self._generate_key() + not_valid_before, not_valid_after = self._get_validity_period() + + subject = x509.Name([ + x509.NameAttribute(NameOID.COMMON_NAME, self.client_cn), + ]) + + cert = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(ca_cert.subject) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(not_valid_before) + .not_valid_after(not_valid_after) + .add_extension( + x509.BasicConstraints(ca=False, path_length=None), + critical=True, + ) + .add_extension( + x509.KeyUsage( + digital_signature=True, + key_encipherment=True, + key_cert_sign=False, + crl_sign=False, + content_commitment=False, + data_encipherment=False, + key_agreement=False, + encipher_only=False, + decipher_only=False, + ), + critical=True, + ) + .add_extension( + x509.ExtendedKeyUsage([x509.oid.ExtendedKeyUsageOID.CLIENT_AUTH]), + critical=False, + ) + .sign(ca_key, hashes.SHA256()) + ) + + return key, cert + + def generate_all(self) -> None: + """Generate complete certificate chain (CA, server, client).""" + self._ca_key, self._ca_cert = self._generate_ca() + self._server_key, self._server_cert = self._generate_server( + self._ca_key, self._ca_cert + ) + self._client_key, self._client_cert = self._generate_client( + self._ca_key, self._ca_cert + ) + + def _serialize_cert(self, cert: x509.Certificate) -> bytes: + """Serialize certificate to PEM format.""" + return cert.public_bytes(serialization.Encoding.PEM) + + def _serialize_key(self, key: rsa.RSAPrivateKey) -> bytes: + """Serialize private key to PEM format (unencrypted).""" + return key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ) + + def write_all(self, output_dir: str) -> None: + """ + Generate and write all certificates to the specified directory. + + Args: + output_dir: Directory to write certificate files + + Creates files based on configured names (defaults shown): + - ca.crt, ca.key (CA certificate and key) + - server.crt, server.key (Server certificate and key) + - client.crt, client.key (Client certificate and key) + """ + os.makedirs(output_dir, exist_ok=True) + + # Generate all certificates + self.generate_all() + + # Write CA files + with open(os.path.join(output_dir, self.ca_cert_name), "wb") as f: + f.write(self._serialize_cert(self._ca_cert)) + with open(os.path.join(output_dir, self.ca_key_name), "wb") as f: + f.write(self._serialize_key(self._ca_key)) + + # Write server files + with open(os.path.join(output_dir, self.server_cert_name), "wb") as f: + f.write(self._serialize_cert(self._server_cert)) + with open(os.path.join(output_dir, self.server_key_name), "wb") as f: + f.write(self._serialize_key(self._server_key)) + + # Write client files + with open(os.path.join(output_dir, self.client_cert_name), "wb") as f: + f.write(self._serialize_cert(self._client_cert)) + with open(os.path.join(output_dir, self.client_key_name), "wb") as f: + f.write(self._serialize_key(self._client_key)) + + def get_client_cn(self) -> str: + """Return the client certificate Common Name (e.g., for GNMI_CLIENT_CERT config).""" + return self.client_cn + + def get_cert_bytes(self) -> dict: + """ + Get all certificates and keys as bytes (for in-memory usage). + + Returns: + Dict with keys: ca_cert, ca_key, server_cert, server_key, + client_cert, client_key + """ + if self._ca_cert is None: + self.generate_all() + + return { + "ca_cert": self._serialize_cert(self._ca_cert), + "ca_key": self._serialize_key(self._ca_key), + "server_cert": self._serialize_cert(self._server_cert), + "server_key": self._serialize_key(self._server_key), + "client_cert": self._serialize_cert(self._client_cert), + "client_key": self._serialize_key(self._client_key), + } + + +def create_gnmi_cert_generator(server_ip: str, **kwargs) -> TlsCertificateGenerator: + """ + Factory function to create a certificate generator with gNMI/gNOI naming conventions. + + This preserves backward compatibility with existing file naming (gnmiCA.cer, + gnmiserver.cer, etc.) while using the generic TlsCertificateGenerator. + + Args: + server_ip: IP address of the server (DUT) to include in server cert SAN + **kwargs: Additional arguments passed to TlsCertificateGenerator + + Returns: + TlsCertificateGenerator configured with gNMI naming conventions + + Example: + generator = create_gnmi_cert_generator(server_ip="10.0.0.1") + generator.write_all("/tmp/gnoi_certs") + # Creates: gnmiCA.cer, gnmiCA.key, gnmiserver.cer, gnmiserver.key, + # gnmiclient.cer, gnmiclient.key + """ + gnmi_defaults = { + "ca_cn": "test.gnmi.sonic", + "server_cn": "test.server.gnmi.sonic", + "client_cn": "test.client.gnmi.sonic", + "ca_cert_name": "gnmiCA.cer", + "ca_key_name": "gnmiCA.key", + "server_cert_name": "gnmiserver.cer", + "server_key_name": "gnmiserver.key", + "client_cert_name": "gnmiclient.cer", + "client_key_name": "gnmiclient.key", + "dns_names": ["hostname.com"], + } + + # User-provided kwargs override defaults + gnmi_defaults.update(kwargs) + + return TlsCertificateGenerator(server_ip=server_ip, **gnmi_defaults) diff --git a/tests/common/fixtures/grpc_fixtures.py b/tests/common/fixtures/grpc_fixtures.py index 10f9c7a4d5b..8a8adcb97fb 100644 --- a/tests/common/fixtures/grpc_fixtures.py +++ b/tests/common/fixtures/grpc_fixtures.py @@ -4,6 +4,7 @@ This module provides pytest fixtures for easy access to gRPC clients with automatic configuration discovery, making it simple to write gRPC-based tests. """ +import os import pytest import logging from tests.common.grpc_config import grpc_config @@ -176,7 +177,7 @@ def test_gnmi_get(ptf_gnmi): @pytest.fixture(scope="module") -def setup_gnoi_tls_server(duthost, localhost, ptfhost): +def setup_gnoi_tls_server(duthost, ptfhost): """ Set up gNOI server with TLS certificates and configuration. @@ -185,7 +186,7 @@ def setup_gnoi_tls_server(duthost, localhost, ptfhost): The fixture: 1. Creates a configuration checkpoint for rollback - 2. Generates TLS certificates with proper SAN for DUT IP + 2. Generates TLS certificates with proper SAN for DUT IP (backdated to handle clock skew) 3. Distributes certificates to DUT and PTF container 4. Configures CONFIG_DB for TLS mode (port 50052) 5. Restarts the gNOI server process @@ -194,7 +195,6 @@ def setup_gnoi_tls_server(duthost, localhost, ptfhost): Args: duthost: DUT host instance to configure - localhost: Localhost instance for certificate generation ptfhost: PTF host instance for client certificates Usage: @@ -207,10 +207,14 @@ def test_gnoi_with_tls(ptf_gnoi): Note: Client fixtures (ptf_grpc, ptf_gnoi) automatically adapt to TLS mode when this fixture is active through GNMIEnvironment detection. + + Certificates are backdated by 1 day to handle clock skew between + the test host, DUT, and PTF container. """ from tests.common.gu_utils import create_checkpoint, rollback checkpoint_name = "gnoi_tls_setup" + cert_dir = "/tmp/gnoi_certs" logger.info("Setting up gNOI TLS server environment") @@ -218,8 +222,8 @@ def test_gnoi_with_tls(ptf_gnoi): create_checkpoint(duthost, checkpoint_name) try: - # 2. Generate certificates - _create_gnoi_certs(duthost, localhost, ptfhost) + # 2. Generate and distribute certificates + _create_gnoi_certs(duthost, ptfhost, cert_dir) # 3. Configure server for TLS mode _configure_gnoi_tls_server(duthost) @@ -243,59 +247,32 @@ def test_gnoi_with_tls(ptf_gnoi): logger.error(f"Failed to rollback configuration: {e}") try: - _delete_gnoi_certs(localhost) + _delete_gnoi_certs(cert_dir) logger.info("Certificate cleanup completed") except Exception as e: logger.error(f"Failed to cleanup certificates: {e}") -def _create_gnoi_certs(duthost, localhost, ptfhost): - """Generate gNOI TLS certificates with proper SAN for DUT IP.""" - logger.info("Generating gNOI TLS certificates") - - # Create all certificate files in /tmp to avoid polluting working directory - cert_dir = "/tmp/gnoi_certs" - localhost.shell(f"mkdir -p {cert_dir}") - localhost.shell(f"cd {cert_dir}") - - # Create Root key - localhost.shell(f"cd {cert_dir} && openssl genrsa -out gnmiCA.key 2048") - - # Create Root cert - localhost.shell(f"""cd {cert_dir} && openssl req -x509 -new -nodes -key gnmiCA.key -sha256 -days 1825 \ - -subj '/CN=test.gnmi.sonic' -out gnmiCA.cer""") - - # Create server key - localhost.shell(f"cd {cert_dir} && openssl genrsa -out gnmiserver.key 2048") - - # Create server CSR - localhost.shell(f"""cd {cert_dir} && openssl req -new -key gnmiserver.key \ - -subj '/CN=test.server.gnmi.sonic' -out gnmiserver.csr""") - - # Create extension file with DUT IP SAN - ext_conf_content = f"""[ req_ext ] -subjectAltName = @alt_names -[alt_names] -DNS.1 = hostname.com -IP = {duthost.mgmt_ip}""" +def _create_gnoi_certs(duthost, ptfhost, cert_dir): + """ + Generate and distribute gNOI TLS certificates. - localhost.shell(f"cd {cert_dir} && echo '{ext_conf_content}' > extfile.cnf") + Certificates are backdated by 1 day to handle clock skew between hosts. - # Sign server certificate with SAN extension - localhost.shell(f"""cd {cert_dir} && openssl x509 -req -in gnmiserver.csr -CA gnmiCA.cer -CAkey gnmiCA.key \ - -CAcreateserial -out gnmiserver.cer -days 825 -sha256 \ - -extensions req_ext -extfile extfile.cnf""") + Args: + duthost: DUT host instance (for IP and copying server certs) + ptfhost: PTF host instance (for copying client certs) + cert_dir: Local directory to store generated certificates + """ + from tests.common.cert_utils import create_gnmi_cert_generator - # Create client key - localhost.shell(f"cd {cert_dir} && openssl genrsa -out gnmiclient.key 2048") + logger.info("Generating gNOI TLS certificates") - # Create client CSR - localhost.shell(f"""cd {cert_dir} && openssl req -new -key gnmiclient.key \ - -subj '/CN=test.client.gnmi.sonic' -out gnmiclient.csr""") + # Generate certificates with 1-day backdating to handle clock skew + generator = create_gnmi_cert_generator(server_ip=duthost.mgmt_ip) + generator.write_all(cert_dir) - # Sign client certificate - localhost.shell(f"""cd {cert_dir} && openssl x509 -req -in gnmiclient.csr -CA gnmiCA.cer -CAkey gnmiCA.key \ - -CAcreateserial -out gnmiclient.cer -days 825 -sha256""") + logger.info(f"Certificates generated in {cert_dir}") # Get certificate copy destinations from centralized config copy_destinations = grpc_config.get_cert_copy_destinations() @@ -398,10 +375,12 @@ def _verify_gnoi_tls_connectivity(duthost, ptfhost): logger.info("TLS connectivity verification completed successfully") -def _delete_gnoi_certs(localhost): +def _delete_gnoi_certs(cert_dir): """Clean up generated certificate files.""" + import shutil + logger.info("Cleaning up certificate files") - # Remove the entire certificate directory in /tmp - cert_dir = "/tmp/gnoi_certs" - localhost.shell(f"rm -rf {cert_dir}", module_ignore_errors=True) + # Remove the entire certificate directory + if os.path.exists(cert_dir): + shutil.rmtree(cert_dir, ignore_errors=True) From 8f460cb453c315e1a3d7e68e2aae6436ae9dcd7b Mon Sep 17 00:00:00 2001 From: vinay-nexthop Date: Fri, 6 Feb 2026 10:16:28 -0800 Subject: [PATCH 02/43] [Disagg T2] Add 64 port T2 topology (#21977) What is the motivation for this PR? Test DUTs with 64 ports connected to peers. How did you do it? How did you verify/test it? Prepare the DUT and verified bgp/test_bgp_fact passes. Signed-off-by: Vinay Kaza * Updated with latest tested topology Signed-off-by: Vinay Kaza * Fix end of file static checker Signed-off-by: Vinay Kaza * Change neighbor VM name from T1 to LT2 Signed-off-by: Vinay Kaza --------- Signed-off-by: Vinay Kaza --- .../templates/t2-single-node-max-64p-core.j2 | 1 + .../templates/t2-single-node-max-64p-leaf.j2 | 1 + ansible/vars/topo_t2_single_node_max_64p.yml | 1753 +++++++++++++++++ 3 files changed, 1755 insertions(+) create mode 120000 ansible/roles/eos/templates/t2-single-node-max-64p-core.j2 create mode 120000 ansible/roles/eos/templates/t2-single-node-max-64p-leaf.j2 create mode 100644 ansible/vars/topo_t2_single_node_max_64p.yml diff --git a/ansible/roles/eos/templates/t2-single-node-max-64p-core.j2 b/ansible/roles/eos/templates/t2-single-node-max-64p-core.j2 new file mode 120000 index 00000000000..5fdcaec5931 --- /dev/null +++ b/ansible/roles/eos/templates/t2-single-node-max-64p-core.j2 @@ -0,0 +1 @@ +t2-core.j2 \ No newline at end of file diff --git a/ansible/roles/eos/templates/t2-single-node-max-64p-leaf.j2 b/ansible/roles/eos/templates/t2-single-node-max-64p-leaf.j2 new file mode 120000 index 00000000000..2556d9c538c --- /dev/null +++ b/ansible/roles/eos/templates/t2-single-node-max-64p-leaf.j2 @@ -0,0 +1 @@ +t2-leaf.j2 \ No newline at end of file diff --git a/ansible/vars/topo_t2_single_node_max_64p.yml b/ansible/vars/topo_t2_single_node_max_64p.yml new file mode 100644 index 00000000000..b1410b7a8f6 --- /dev/null +++ b/ansible/vars/topo_t2_single_node_max_64p.yml @@ -0,0 +1,1753 @@ +topology: + VMs: + VM01T3: + vlans: + - 0 + vm_offset: 0 + + VM02T3: + vlans: + - 1 + vm_offset: 1 + + VM03T3: + vlans: + - 2 + vm_offset: 2 + + VM04T3: + vlans: + - 3 + vm_offset: 3 + + VM05T3: + vlans: + - 4 + vm_offset: 4 + + VM06T3: + vlans: + - 5 + vm_offset: 5 + + VM07T3: + vlans: + - 6 + vm_offset: 6 + + VM08T3: + vlans: + - 7 + vm_offset: 7 + + VM09T3: + vlans: + - 8 + vm_offset: 8 + + VM10T3: + vlans: + - 9 + vm_offset: 9 + + VM11T3: + vlans: + - 10 + vm_offset: 10 + + VM12T3: + vlans: + - 11 + vm_offset: 11 + + VM13T3: + vlans: + - 12 + vm_offset: 12 + + VM14T3: + vlans: + - 13 + vm_offset: 13 + + VM15T3: + vlans: + - 14 + vm_offset: 14 + + VM16T3: + vlans: + - 15 + vm_offset: 15 + + VM17T3: + vlans: + - 16 + vm_offset: 16 + + VM18T3: + vlans: + - 17 + vm_offset: 17 + + VM19T3: + vlans: + - 18 + vm_offset: 18 + + VM20T3: + vlans: + - 19 + vm_offset: 19 + + VM21T3: + vlans: + - 20 + vm_offset: 20 + + VM22T3: + vlans: + - 21 + vm_offset: 21 + + VM23T3: + vlans: + - 22 + vm_offset: 22 + + VM24T3: + vlans: + - 23 + vm_offset: 23 + + VM25T3: + vlans: + - 24 + vm_offset: 24 + + VM26T3: + vlans: + - 25 + vm_offset: 25 + + VM27T3: + vlans: + - 26 + vm_offset: 26 + + VM28T3: + vlans: + - 27 + vm_offset: 27 + + VM29T3: + vlans: + - 28 + vm_offset: 28 + + VM30T3: + vlans: + - 29 + vm_offset: 29 + + VM31T3: + vlans: + - 30 + vm_offset: 30 + + VM32T3: + vlans: + - 31 + vm_offset: 31 + + VM01LT2: + vlans: + - 32 + vm_offset: 32 + + VM02LT2: + vlans: + - 33 + vm_offset: 33 + + VM03LT2: + vlans: + - 34 + vm_offset: 34 + + VM04LT2: + vlans: + - 35 + vm_offset: 35 + + VM05LT2: + vlans: + - 36 + vm_offset: 36 + + VM06LT2: + vlans: + - 37 + vm_offset: 37 + + VM07LT2: + vlans: + - 38 + vm_offset: 38 + + VM08LT2: + vlans: + - 39 + vm_offset: 39 + + VM09LT2: + vlans: + - 40 + vm_offset: 40 + + VM10LT2: + vlans: + - 41 + vm_offset: 41 + + VM11LT2: + vlans: + - 42 + vm_offset: 42 + + VM12LT2: + vlans: + - 43 + vm_offset: 43 + + VM13LT2: + vlans: + - 44 + vm_offset: 44 + + VM14LT2: + vlans: + - 45 + vm_offset: 45 + + VM15LT2: + vlans: + - 46 + vm_offset: 46 + + VM16LT2: + vlans: + - 47 + vm_offset: 47 + + VM17LT2: + vlans: + - 48 + vm_offset: 48 + + VM18LT2: + vlans: + - 49 + vm_offset: 49 + + VM19LT2: + vlans: + - 50 + vm_offset: 50 + + VM20LT2: + vlans: + - 51 + vm_offset: 51 + + VM21LT2: + vlans: + - 52 + vm_offset: 52 + + VM22LT2: + vlans: + - 53 + vm_offset: 53 + + VM23LT2: + vlans: + - 54 + vm_offset: 54 + + VM24LT2: + vlans: + - 55 + vm_offset: 55 + + VM25LT2: + vlans: + - 56 + vm_offset: 56 + + VM26LT2: + vlans: + - 57 + vm_offset: 57 + + VM27LT2: + vlans: + - 58 + vm_offset: 58 + + VM28LT2: + vlans: + - 59 + vm_offset: 59 + + VM29LT2: + vlans: + - 60 + vm_offset: 60 + + VM30LT2: + vlans: + - 61 + vm_offset: 61 + + VM31LT2: + vlans: + - 62 + vm_offset: 62 + + VM32LT2: + vlans: + - 63 + vm_offset: 63 + + DUT: + loopback: + ipv4: + - 10.1.0.1/32 + ipv6: + - fc00:10::1/128 + +configuration_properties: + common: + dut_asn: 65100 + dut_type: SpineRouter + podset_number: 400 + tor_number: 16 + tor_subnet_number: 8 + max_tor_subnet_number: 32 + tor_subnet_size: 128 + nhipv4: 10.10.48.254 + nhipv6: fc30::ff + core: + swrole: core + leaf: + swrole: leaf + +configuration: + VM01T3: + properties: + - common + - core + bgp: + asn: 65200 + peers: + 65100: + - 10.0.0.0 + - fc00::1 + interfaces: + Loopback0: + ipv4: 100.1.0.1/32 + ipv6: 2064:100::1/128 + Port-Channel1: + ipv4: 10.0.0.1/31 + ipv6: fc00::2/126 + Ethernet1: + lacp: 1 + bp_interface: + ipv4: 10.10.48.1/24 + ipv6: fc30::2/64 + + VM02T3: + properties: + - common + - core + bgp: + asn: 65200 + peers: + 65100: + - 10.0.0.2 + - fc00::5 + interfaces: + Loopback0: + ipv4: 100.1.0.2/32 + ipv6: 2064:100::2/128 + Port-Channel1: + ipv4: 10.0.0.3/31 + ipv6: fc00::6/126 + Ethernet1: + lacp: 1 + bp_interface: + ipv4: 10.10.48.2/24 + ipv6: fc30::3/64 + + VM03T3: + properties: + - common + - core + bgp: + asn: 65200 + peers: + 65100: + - 10.0.0.4 + - fc00::9 + interfaces: + Loopback0: + ipv4: 100.1.0.3/32 + ipv6: 2064:100::3/128 + Port-Channel1: + ipv4: 10.0.0.5/31 + ipv6: fc00::a/126 + Ethernet1: + lacp: 1 + bp_interface: + ipv4: 10.10.48.3/24 + ipv6: fc30::4/64 + + VM04T3: + properties: + - common + - core + bgp: + asn: 65200 + peers: + 65100: + - 10.0.0.6 + - fc00::d + interfaces: + Loopback0: + ipv4: 100.1.0.4/32 + ipv6: 2064:100::4/128 + Port-Channel1: + ipv4: 10.0.0.7/31 + ipv6: fc00::e/126 + Ethernet1: + lacp: 1 + bp_interface: + ipv4: 10.10.48.4/24 + ipv6: fc30::5/64 + + VM05T3: + properties: + - common + - core + bgp: + asn: 65200 + peers: + 65100: + - 10.0.0.8 + - fc00::11 + interfaces: + Loopback0: + ipv4: 100.1.0.5/32 + ipv6: 2064:100::5/128 + Port-Channel1: + ipv4: 10.0.0.9/31 + ipv6: fc00::12/126 + Ethernet1: + lacp: 1 + bp_interface: + ipv4: 10.10.48.5/24 + ipv6: fc30::6/64 + + VM06T3: + properties: + - common + - core + bgp: + asn: 65200 + peers: + 65100: + - 10.0.0.10 + - fc00::15 + interfaces: + Loopback0: + ipv4: 100.1.0.6/32 + ipv6: 2064:100::6/128 + Port-Channel1: + ipv4: 10.0.0.11/31 + ipv6: fc00::16/126 + Ethernet1: + lacp: 1 + bp_interface: + ipv4: 10.10.48.6/24 + ipv6: fc30::7/64 + + VM07T3: + properties: + - common + - core + bgp: + asn: 65200 + peers: + 65100: + - 10.0.0.12 + - fc00::19 + interfaces: + Loopback0: + ipv4: 100.1.0.7/32 + ipv6: 2064:100::7/128 + Port-Channel1: + ipv4: 10.0.0.13/31 + ipv6: fc00::1a/126 + Ethernet1: + lacp: 1 + bp_interface: + ipv4: 10.10.48.7/24 + ipv6: fc30::8/64 + + VM08T3: + properties: + - common + - core + bgp: + asn: 65200 + peers: + 65100: + - 10.0.0.14 + - fc00::1d + interfaces: + Loopback0: + ipv4: 100.1.0.8/32 + ipv6: 2064:100::8/128 + Port-Channel1: + ipv4: 10.0.0.15/31 + ipv6: fc00::1e/126 + Ethernet1: + lacp: 1 + bp_interface: + ipv4: 10.10.48.8/24 + ipv6: fc30::9/64 + + VM09T3: + properties: + - common + - core + bgp: + asn: 65200 + peers: + 65100: + - 10.0.0.16 + - fc00::21 + interfaces: + Loopback0: + ipv4: 100.1.0.9/32 + ipv6: 2064:100::9/128 + Port-Channel1: + ipv4: 10.0.0.17/31 + ipv6: fc00::22/126 + Ethernet1: + lacp: 1 + bp_interface: + ipv4: 10.10.48.9/24 + ipv6: fc30::a/64 + + VM10T3: + properties: + - common + - core + bgp: + asn: 65200 + peers: + 65100: + - 10.0.0.18 + - fc00::25 + interfaces: + Loopback0: + ipv4: 100.1.0.10/32 + ipv6: 2064:100::a/128 + Port-Channel1: + ipv4: 10.0.0.19/31 + ipv6: fc00::26/126 + Ethernet1: + lacp: 1 + bp_interface: + ipv4: 10.10.48.10/24 + ipv6: fc30::b/64 + + VM11T3: + properties: + - common + - core + bgp: + asn: 65200 + peers: + 65100: + - 10.0.0.20 + - fc00::29 + interfaces: + Loopback0: + ipv4: 100.1.0.11/32 + ipv6: 2064:100::b/128 + Port-Channel1: + ipv4: 10.0.0.21/31 + ipv6: fc00::2a/126 + Ethernet1: + lacp: 1 + bp_interface: + ipv4: 10.10.48.11/24 + ipv6: fc30::c/64 + + VM12T3: + properties: + - common + - core + bgp: + asn: 65200 + peers: + 65100: + - 10.0.0.22 + - fc00::2d + interfaces: + Loopback0: + ipv4: 100.1.0.12/32 + ipv6: 2064:100::c/128 + Port-Channel1: + ipv4: 10.0.0.23/31 + ipv6: fc00::2e/126 + Ethernet1: + lacp: 1 + bp_interface: + ipv4: 10.10.48.12/24 + ipv6: fc30::d/64 + + VM13T3: + properties: + - common + - core + bgp: + asn: 65200 + peers: + 65100: + - 10.0.0.24 + - fc00::31 + interfaces: + Loopback0: + ipv4: 100.1.0.13/32 + ipv6: 2064:100::d/128 + Port-Channel1: + ipv4: 10.0.0.25/31 + ipv6: fc00::32/126 + Ethernet1: + lacp: 1 + bp_interface: + ipv4: 10.10.48.13/24 + ipv6: fc30::e/64 + + VM14T3: + properties: + - common + - core + bgp: + asn: 65200 + peers: + 65100: + - 10.0.0.26 + - fc00::35 + interfaces: + Loopback0: + ipv4: 100.1.0.14/32 + ipv6: 2064:100::e/128 + Port-Channel1: + ipv4: 10.0.0.27/31 + ipv6: fc00::36/126 + Ethernet1: + lacp: 1 + bp_interface: + ipv4: 10.10.48.14/24 + ipv6: fc30::f/64 + + VM15T3: + properties: + - common + - core + bgp: + asn: 65200 + peers: + 65100: + - 10.0.0.28 + - fc00::39 + interfaces: + Loopback0: + ipv4: 100.1.0.15/32 + ipv6: 2064:100::f/128 + Port-Channel1: + ipv4: 10.0.0.29/31 + ipv6: fc00::3a/126 + Ethernet1: + lacp: 1 + bp_interface: + ipv4: 10.10.48.15/24 + ipv6: fc30::10/64 + + VM16T3: + properties: + - common + - core + bgp: + asn: 65200 + peers: + 65100: + - 10.0.0.30 + - fc00::3d + interfaces: + Loopback0: + ipv4: 100.1.0.16/32 + ipv6: 2064:100::10/128 + Port-Channel1: + ipv4: 10.0.0.31/31 + ipv6: fc00::3e/126 + Ethernet1: + lacp: 1 + bp_interface: + ipv4: 10.10.48.16/24 + ipv6: fc30::11/64 + + VM17T3: + properties: + - common + - core + bgp: + asn: 65200 + peers: + 65100: + - 10.0.0.32 + - fc00::41 + interfaces: + Loopback0: + ipv4: 100.1.0.17/32 + ipv6: 2064:100::11/128 + Port-Channel1: + ipv4: 10.0.0.33/31 + ipv6: fc00::42/126 + Ethernet1: + lacp: 1 + bp_interface: + ipv4: 10.10.48.17/24 + ipv6: fc30::12/64 + + VM18T3: + properties: + - common + - core + bgp: + asn: 65200 + peers: + 65100: + - 10.0.0.34 + - fc00::45 + interfaces: + Loopback0: + ipv4: 100.1.0.18/32 + ipv6: 2064:100::12/128 + Port-Channel1: + ipv4: 10.0.0.35/31 + ipv6: fc00::46/126 + Ethernet1: + lacp: 1 + bp_interface: + ipv4: 10.10.48.18/24 + ipv6: fc30::13/64 + + VM19T3: + properties: + - common + - core + bgp: + asn: 65200 + peers: + 65100: + - 10.0.0.36 + - fc00::49 + interfaces: + Loopback0: + ipv4: 100.1.0.19/32 + ipv6: 2064:100::13/128 + Port-Channel1: + ipv4: 10.0.0.37/31 + ipv6: fc00::4a/126 + Ethernet1: + lacp: 1 + bp_interface: + ipv4: 10.10.48.19/24 + ipv6: fc30::14/64 + + VM20T3: + properties: + - common + - core + bgp: + asn: 65200 + peers: + 65100: + - 10.0.0.38 + - fc00::4d + interfaces: + Loopback0: + ipv4: 100.1.0.20/32 + ipv6: 2064:100::14/128 + Port-Channel1: + ipv4: 10.0.0.39/31 + ipv6: fc00::4e/126 + Ethernet1: + lacp: 1 + bp_interface: + ipv4: 10.10.48.20/24 + ipv6: fc30::15/64 + + VM21T3: + properties: + - common + - core + bgp: + asn: 65200 + peers: + 65100: + - 10.0.0.40 + - fc00::51 + interfaces: + Loopback0: + ipv4: 100.1.0.21/32 + ipv6: 2064:100::15/128 + Port-Channel1: + ipv4: 10.0.0.41/31 + ipv6: fc00::52/126 + Ethernet1: + lacp: 1 + bp_interface: + ipv4: 10.10.48.21/24 + ipv6: fc30::16/64 + + VM22T3: + properties: + - common + - core + bgp: + asn: 65200 + peers: + 65100: + - 10.0.0.42 + - fc00::55 + interfaces: + Loopback0: + ipv4: 100.1.0.22/32 + ipv6: 2064:100::16/128 + Port-Channel1: + ipv4: 10.0.0.43/31 + ipv6: fc00::56/126 + Ethernet1: + lacp: 1 + bp_interface: + ipv4: 10.10.48.22/24 + ipv6: fc30::17/64 + + VM23T3: + properties: + - common + - core + bgp: + asn: 65200 + peers: + 65100: + - 10.0.0.44 + - fc00::59 + interfaces: + Loopback0: + ipv4: 100.1.0.23/32 + ipv6: 2064:100::17/128 + Port-Channel1: + ipv4: 10.0.0.45/31 + ipv6: fc00::5a/126 + Ethernet1: + lacp: 1 + bp_interface: + ipv4: 10.10.48.23/24 + ipv6: fc30::18/64 + + VM24T3: + properties: + - common + - core + bgp: + asn: 65200 + peers: + 65100: + - 10.0.0.46 + - fc00::5d + interfaces: + Loopback0: + ipv4: 100.1.0.24/32 + ipv6: 2064:100::18/128 + Port-Channel1: + ipv4: 10.0.0.47/31 + ipv6: fc00::5e/126 + Ethernet1: + lacp: 1 + bp_interface: + ipv4: 10.10.48.24/24 + ipv6: fc30::19/64 + + VM25T3: + properties: + - common + - core + bgp: + asn: 65200 + peers: + 65100: + - 10.0.0.48 + - fc00::61 + interfaces: + Loopback0: + ipv4: 100.1.0.25/32 + ipv6: 2064:100::19/128 + Port-Channel1: + ipv4: 10.0.0.49/31 + ipv6: fc00::62/126 + Ethernet1: + lacp: 1 + bp_interface: + ipv4: 10.10.48.25/24 + ipv6: fc30::1a/64 + + VM26T3: + properties: + - common + - core + bgp: + asn: 65200 + peers: + 65100: + - 10.0.0.50 + - fc00::65 + interfaces: + Loopback0: + ipv4: 100.1.0.26/32 + ipv6: 2064:100::1a/128 + Port-Channel1: + ipv4: 10.0.0.51/31 + ipv6: fc00::66/126 + Ethernet1: + lacp: 1 + bp_interface: + ipv4: 10.10.48.26/24 + ipv6: fc30::1b/64 + + VM27T3: + properties: + - common + - core + bgp: + asn: 65200 + peers: + 65100: + - 10.0.0.52 + - fc00::69 + interfaces: + Loopback0: + ipv4: 100.1.0.27/32 + ipv6: 2064:100::1b/128 + Port-Channel1: + ipv4: 10.0.0.53/31 + ipv6: fc00::6a/126 + Ethernet1: + lacp: 1 + bp_interface: + ipv4: 10.10.48.27/24 + ipv6: fc30::1c/64 + + VM28T3: + properties: + - common + - core + bgp: + asn: 65200 + peers: + 65100: + - 10.0.0.54 + - fc00::6d + interfaces: + Loopback0: + ipv4: 100.1.0.28/32 + ipv6: 2064:100::1c/128 + Port-Channel1: + ipv4: 10.0.0.55/31 + ipv6: fc00::6e/126 + Ethernet1: + lacp: 1 + bp_interface: + ipv4: 10.10.48.28/24 + ipv6: fc30::1d/64 + + VM29T3: + properties: + - common + - core + bgp: + asn: 65200 + peers: + 65100: + - 10.0.0.56 + - fc00::71 + interfaces: + Loopback0: + ipv4: 100.1.0.29/32 + ipv6: 2064:100::1d/128 + Port-Channel1: + ipv4: 10.0.0.57/31 + ipv6: fc00::72/126 + Ethernet1: + lacp: 1 + bp_interface: + ipv4: 10.10.48.29/24 + ipv6: fc30::1e/64 + + VM30T3: + properties: + - common + - core + bgp: + asn: 65200 + peers: + 65100: + - 10.0.0.58 + - fc00::75 + interfaces: + Loopback0: + ipv4: 100.1.0.30/32 + ipv6: 2064:100::1e/128 + Port-Channel1: + ipv4: 10.0.0.59/31 + ipv6: fc00::76/126 + Ethernet1: + lacp: 1 + bp_interface: + ipv4: 10.10.48.30/24 + ipv6: fc30::1f/64 + + VM31T3: + properties: + - common + - core + bgp: + asn: 65200 + peers: + 65100: + - 10.0.0.60 + - fc00::79 + interfaces: + Loopback0: + ipv4: 100.1.0.31/32 + ipv6: 2064:100::1f/128 + Port-Channel1: + ipv4: 10.0.0.61/31 + ipv6: fc00::7a/126 + Ethernet1: + lacp: 1 + bp_interface: + ipv4: 10.10.48.31/24 + ipv6: fc30::20/64 + + VM32T3: + properties: + - common + - core + bgp: + asn: 65200 + peers: + 65100: + - 10.0.0.62 + - fc00::7d + interfaces: + Loopback0: + ipv4: 100.1.0.32/32 + ipv6: 2064:100::20/128 + Port-Channel1: + ipv4: 10.0.0.63/31 + ipv6: fc00::7e/126 + Ethernet1: + lacp: 1 + bp_interface: + ipv4: 10.10.48.32/24 + ipv6: fc30::21/64 + + VM01LT2: + properties: + - common + - leaf + bgp: + asn: 64600 + peers: + 65100: + - 10.0.0.128 + - fc00::101 + interfaces: + Loopback0: + ipv4: 100.1.0.65/32 + ipv6: 2064:100::41/128 + Ethernet1: + ipv4: 10.0.0.129/31 + ipv6: fc00::102/126 + bp_interface: + ipv4: 10.10.48.65/24 + ipv6: fc30::42/64 + + VM02LT2: + properties: + - common + - leaf + bgp: + asn: 64610 + peers: + 65100: + - 10.0.0.130 + - fc00::105 + interfaces: + Loopback0: + ipv4: 100.1.0.66/32 + ipv6: 2064:100::42/128 + Ethernet1: + ipv4: 10.0.0.131/31 + ipv6: fc00::106/126 + bp_interface: + ipv4: 10.10.48.66/24 + ipv6: fc30::43/64 + + VM03LT2: + properties: + - common + - leaf + bgp: + asn: 64620 + peers: + 65100: + - 10.0.0.132 + - fc00::109 + interfaces: + Loopback0: + ipv4: 100.1.0.67/32 + ipv6: 2064:100::43/128 + Ethernet1: + ipv4: 10.0.0.133/31 + ipv6: fc00::10a/126 + bp_interface: + ipv4: 10.10.48.67/24 + ipv6: fc30::44/64 + + VM04LT2: + properties: + - common + - leaf + bgp: + asn: 64630 + peers: + 65100: + - 10.0.0.134 + - fc00::10d + interfaces: + Loopback0: + ipv4: 100.1.0.68/32 + ipv6: 2064:100::44/128 + Ethernet1: + ipv4: 10.0.0.135/31 + ipv6: fc00::10e/126 + bp_interface: + ipv4: 10.10.48.68/24 + ipv6: fc30::45/64 + + VM05LT2: + properties: + - common + - leaf + bgp: + asn: 64640 + peers: + 65100: + - 10.0.0.136 + - fc00::111 + interfaces: + Loopback0: + ipv4: 100.1.0.69/32 + ipv6: 2064:100::45/128 + Ethernet1: + ipv4: 10.0.0.137/31 + ipv6: fc00::112/126 + bp_interface: + ipv4: 10.10.48.69/24 + ipv6: fc30::46/64 + + VM06LT2: + properties: + - common + - leaf + bgp: + asn: 64650 + peers: + 65100: + - 10.0.0.138 + - fc00::115 + interfaces: + Loopback0: + ipv4: 100.1.0.70/32 + ipv6: 2064:100::46/128 + Ethernet1: + ipv4: 10.0.0.139/31 + ipv6: fc00::116/126 + bp_interface: + ipv4: 10.10.48.70/24 + ipv6: fc30::47/64 + + VM07LT2: + properties: + - common + - leaf + bgp: + asn: 64660 + peers: + 65100: + - 10.0.0.140 + - fc00::119 + interfaces: + Loopback0: + ipv4: 100.1.0.71/32 + ipv6: 2064:100::47/128 + Ethernet1: + ipv4: 10.0.0.141/31 + ipv6: fc00::11a/126 + bp_interface: + ipv4: 10.10.48.71/24 + ipv6: fc30::48/64 + + VM08LT2: + properties: + - common + - leaf + bgp: + asn: 64670 + peers: + 65100: + - 10.0.0.142 + - fc00::11d + interfaces: + Loopback0: + ipv4: 100.1.0.72/32 + ipv6: 2064:100::48/128 + Ethernet1: + ipv4: 10.0.0.143/31 + ipv6: fc00::11e/126 + bp_interface: + ipv4: 10.10.48.72/24 + ipv6: fc30::49/64 + + VM09LT2: + properties: + - common + - leaf + bgp: + asn: 64680 + peers: + 65100: + - 10.0.0.144 + - fc00::121 + interfaces: + Loopback0: + ipv4: 100.1.0.73/32 + ipv6: 2064:100::49/128 + Ethernet1: + ipv4: 10.0.0.145/31 + ipv6: fc00::122/126 + bp_interface: + ipv4: 10.10.48.73/24 + ipv6: fc30::4a/64 + + VM10LT2: + properties: + - common + - leaf + bgp: + asn: 64690 + peers: + 65100: + - 10.0.0.146 + - fc00::125 + interfaces: + Loopback0: + ipv4: 100.1.0.74/32 + ipv6: 2064:100::4a/128 + Ethernet1: + ipv4: 10.0.0.147/31 + ipv6: fc00::126/126 + bp_interface: + ipv4: 10.10.48.74/24 + ipv6: fc30::4b/64 + + VM11LT2: + properties: + - common + - leaf + bgp: + asn: 64700 + peers: + 65100: + - 10.0.0.148 + - fc00::129 + interfaces: + Loopback0: + ipv4: 100.1.0.75/32 + ipv6: 2064:100::4b/128 + Ethernet1: + ipv4: 10.0.0.149/31 + ipv6: fc00::12a/126 + bp_interface: + ipv4: 10.10.48.75/24 + ipv6: fc30::4c/64 + + VM12LT2: + properties: + - common + - leaf + bgp: + asn: 64710 + peers: + 65100: + - 10.0.0.150 + - fc00::12d + interfaces: + Loopback0: + ipv4: 100.1.0.76/32 + ipv6: 2064:100::4c/128 + Ethernet1: + ipv4: 10.0.0.151/31 + ipv6: fc00::12e/126 + bp_interface: + ipv4: 10.10.48.76/24 + ipv6: fc30::4d/64 + + VM13LT2: + properties: + - common + - leaf + bgp: + asn: 64720 + peers: + 65100: + - 10.0.0.152 + - fc00::131 + interfaces: + Loopback0: + ipv4: 100.1.0.77/32 + ipv6: 2064:100::4d/128 + Ethernet1: + ipv4: 10.0.0.153/31 + ipv6: fc00::132/126 + bp_interface: + ipv4: 10.10.48.77/24 + ipv6: fc30::4e/64 + + VM14LT2: + properties: + - common + - leaf + bgp: + asn: 64730 + peers: + 65100: + - 10.0.0.154 + - fc00::135 + interfaces: + Loopback0: + ipv4: 100.1.0.78/32 + ipv6: 2064:100::4e/128 + Ethernet1: + ipv4: 10.0.0.155/31 + ipv6: fc00::136/126 + bp_interface: + ipv4: 10.10.48.78/24 + ipv6: fc30::4f/64 + + VM15LT2: + properties: + - common + - leaf + bgp: + asn: 64740 + peers: + 65100: + - 10.0.0.156 + - fc00::139 + interfaces: + Loopback0: + ipv4: 100.1.0.79/32 + ipv6: 2064:100::4f/128 + Ethernet1: + ipv4: 10.0.0.157/31 + ipv6: fc00::13a/126 + bp_interface: + ipv4: 10.10.48.79/24 + ipv6: fc30::50/64 + + VM16LT2: + properties: + - common + - leaf + bgp: + asn: 64750 + peers: + 65100: + - 10.0.0.158 + - fc00::13d + interfaces: + Loopback0: + ipv4: 100.1.0.80/32 + ipv6: 2064:100::50/128 + Ethernet1: + ipv4: 10.0.0.159/31 + ipv6: fc00::13e/126 + bp_interface: + ipv4: 10.10.48.80/24 + ipv6: fc30::51/64 + + VM17LT2: + properties: + - common + - leaf + bgp: + asn: 64760 + peers: + 65100: + - 10.0.0.160 + - fc00::141 + interfaces: + Loopback0: + ipv4: 100.1.0.81/32 + ipv6: 2064:100::51/128 + Ethernet1: + ipv4: 10.0.0.161/31 + ipv6: fc00::142/126 + bp_interface: + ipv4: 10.10.48.81/24 + ipv6: fc30::52/64 + + VM18LT2: + properties: + - common + - leaf + bgp: + asn: 64770 + peers: + 65100: + - 10.0.0.162 + - fc00::145 + interfaces: + Loopback0: + ipv4: 100.1.0.82/32 + ipv6: 2064:100::52/128 + Ethernet1: + ipv4: 10.0.0.163/31 + ipv6: fc00::146/126 + bp_interface: + ipv4: 10.10.48.82/24 + ipv6: fc30::53/64 + + VM19LT2: + properties: + - common + - leaf + bgp: + asn: 64780 + peers: + 65100: + - 10.0.0.164 + - fc00::149 + interfaces: + Loopback0: + ipv4: 100.1.0.83/32 + ipv6: 2064:100::53/128 + Ethernet1: + ipv4: 10.0.0.165/31 + ipv6: fc00::14a/126 + bp_interface: + ipv4: 10.10.48.83/24 + ipv6: fc30::54/64 + + VM20LT2: + properties: + - common + - leaf + bgp: + asn: 64790 + peers: + 65100: + - 10.0.0.166 + - fc00::14d + interfaces: + Loopback0: + ipv4: 100.1.0.84/32 + ipv6: 2064:100::54/128 + Ethernet1: + ipv4: 10.0.0.167/31 + ipv6: fc00::14e/126 + bp_interface: + ipv4: 10.10.48.84/24 + ipv6: fc30::55/64 + + VM21LT2: + properties: + - common + - leaf + bgp: + asn: 64800 + peers: + 65100: + - 10.0.0.168 + - fc00::151 + interfaces: + Loopback0: + ipv4: 100.1.0.85/32 + ipv6: 2064:100::55/128 + Ethernet1: + ipv4: 10.0.0.169/31 + ipv6: fc00::152/126 + bp_interface: + ipv4: 10.10.48.85/24 + ipv6: fc30::56/64 + + VM22LT2: + properties: + - common + - leaf + bgp: + asn: 64810 + peers: + 65100: + - 10.0.0.170 + - fc00::155 + interfaces: + Loopback0: + ipv4: 100.1.0.86/32 + ipv6: 2064:100::56/128 + Ethernet1: + ipv4: 10.0.0.171/31 + ipv6: fc00::156/126 + bp_interface: + ipv4: 10.10.48.86/24 + ipv6: fc30::57/64 + + VM23LT2: + properties: + - common + - leaf + bgp: + asn: 64820 + peers: + 65100: + - 10.0.0.172 + - fc00::159 + interfaces: + Loopback0: + ipv4: 100.1.0.87/32 + ipv6: 2064:100::57/128 + Ethernet1: + ipv4: 10.0.0.173/31 + ipv6: fc00::15a/126 + bp_interface: + ipv4: 10.10.48.87/24 + ipv6: fc30::58/64 + + VM24LT2: + properties: + - common + - leaf + bgp: + asn: 64830 + peers: + 65100: + - 10.0.0.174 + - fc00::15d + interfaces: + Loopback0: + ipv4: 100.1.0.88/32 + ipv6: 2064:100::58/128 + Ethernet1: + ipv4: 10.0.0.175/31 + ipv6: fc00::15e/126 + bp_interface: + ipv4: 10.10.48.88/24 + ipv6: fc30::59/64 + + VM25LT2: + properties: + - common + - leaf + bgp: + asn: 64840 + peers: + 65100: + - 10.0.0.176 + - fc00::161 + interfaces: + Loopback0: + ipv4: 100.1.0.89/32 + ipv6: 2064:100::59/128 + Ethernet1: + ipv4: 10.0.0.177/31 + ipv6: fc00::162/126 + bp_interface: + ipv4: 10.10.48.89/24 + ipv6: fc30::5a/64 + + VM26LT2: + properties: + - common + - leaf + bgp: + asn: 64850 + peers: + 65100: + - 10.0.0.178 + - fc00::165 + interfaces: + Loopback0: + ipv4: 100.1.0.90/32 + ipv6: 2064:100::5a/128 + Ethernet1: + ipv4: 10.0.0.179/31 + ipv6: fc00::166/126 + bp_interface: + ipv4: 10.10.48.90/24 + ipv6: fc30::5b/64 + + VM27LT2: + properties: + - common + - leaf + bgp: + asn: 64860 + peers: + 65100: + - 10.0.0.180 + - fc00::169 + interfaces: + Loopback0: + ipv4: 100.1.0.91/32 + ipv6: 2064:100::5b/128 + Ethernet1: + ipv4: 10.0.0.181/31 + ipv6: fc00::16a/126 + bp_interface: + ipv4: 10.10.48.91/24 + ipv6: fc30::5c/64 + + VM28LT2: + properties: + - common + - leaf + bgp: + asn: 64870 + peers: + 65100: + - 10.0.0.182 + - fc00::16d + interfaces: + Loopback0: + ipv4: 100.1.0.92/32 + ipv6: 2064:100::5c/128 + Ethernet1: + ipv4: 10.0.0.183/31 + ipv6: fc00::16e/126 + bp_interface: + ipv4: 10.10.48.92/24 + ipv6: fc30::5d/64 + + VM29LT2: + properties: + - common + - leaf + bgp: + asn: 64880 + peers: + 65100: + - 10.0.0.184 + - fc00::171 + interfaces: + Loopback0: + ipv4: 100.1.0.93/32 + ipv6: 2064:100::5d/128 + Ethernet1: + ipv4: 10.0.0.185/31 + ipv6: fc00::172/126 + bp_interface: + ipv4: 10.10.48.93/24 + ipv6: fc30::5e/64 + + VM30LT2: + properties: + - common + - leaf + bgp: + asn: 64890 + peers: + 65100: + - 10.0.0.186 + - fc00::175 + interfaces: + Loopback0: + ipv4: 100.1.0.94/32 + ipv6: 2064:100::5e/128 + Ethernet1: + ipv4: 10.0.0.187/31 + ipv6: fc00::176/126 + bp_interface: + ipv4: 10.10.48.94/24 + ipv6: fc30::5f/64 + + VM31LT2: + properties: + - common + - leaf + bgp: + asn: 64900 + peers: + 65100: + - 10.0.0.188 + - fc00::179 + interfaces: + Loopback0: + ipv4: 100.1.0.95/32 + ipv6: 2064:100::5f/128 + Ethernet1: + ipv4: 10.0.0.189/31 + ipv6: fc00::17a/126 + bp_interface: + ipv4: 10.10.48.95/24 + ipv6: fc30::60/64 + + VM32LT2: + properties: + - common + - leaf + bgp: + asn: 64910 + peers: + 65100: + - 10.0.0.190 + - fc00::17d + interfaces: + Loopback0: + ipv4: 100.1.0.96/32 + ipv6: 2064:100::60/128 + Ethernet1: + ipv4: 10.0.0.191/31 + ipv6: fc00::17e/126 + bp_interface: + ipv4: 10.10.48.96/24 + ipv6: fc30::61/64 From 2ff1ee935702e1dca3578c06ee3472d3cbbd7704 Mon Sep 17 00:00:00 2001 From: Priyansh <77935498+PriyanshTratiya@users.noreply.github.com> Date: Fri, 6 Feb 2026 17:05:06 -0800 Subject: [PATCH 03/43] Reduce BGP Queue test runtime with a single 'show queue counters' cmd (#21990) What is the motivation for this PR? Running many individual SSH commands in test scripts resulted in excessive overhead due to repeated SSH session establishments, leading to unnecessarily long test times. The total test time was 1 hr 26 mins, current implementation makes one SSH call for 450 ports and its each 7 UC queues separately, making roughly 3150 ssh commands assuming each SSH call takes 1 to 1.5 mins, 1 hr 20 mins of time is spent here fetching the data. The motivation is to improve test efficiency and make large test runs more practical by reducing SSH connection overhead. How did you do it? Identified places in the test where SSH commands are executed in sequence or within loops. Rewrote those sections to batch multiple commands together and execute them through a single SSH session, fetching show queue counters for all ports and queue together. Ensured that command outputs and error handling remained compatible with existing test validations. How did you verify/test it? Ran affected test scripts before and after the changes to compare results and execution time. Verified that all expected outputs and pass/fail criteria remain the same. Monitored for regressions, failures, or unexpected test behaviors possibly introduced by batching commands. The execution time dropped from 1 hr 26 mins to 3 mins. Signed-off-by: Priyansh Tratiya --- tests/bgp/test_bgp_queue.py | 79 +++++++++++++++++++++++-------------- 1 file changed, 50 insertions(+), 29 deletions(-) diff --git a/tests/bgp/test_bgp_queue.py b/tests/bgp/test_bgp_queue.py index 4b4c6fe1d14..a87b0470f0a 100644 --- a/tests/bgp/test_bgp_queue.py +++ b/tests/bgp/test_bgp_queue.py @@ -14,19 +14,38 @@ def clear_queue_counters(asichost): asichost.command("sonic-clear queuecounters") -def get_queue_counters(asichost, port, queue): +def get_all_ports_queue_counters(asichost, queue_type_prefix="UC"): """ - Return the counter for a given queue in given port + Fetch queue counters for ALL ports in a single command. + Returns a dict: {port_name: {queue_num: count, ...}, ...} + Example: {'Ethernet0': {0: 0, 1: 0, ...}, 'Ethernet4': {0: 0, 1: 0, ...}} """ - cmd = "show queue counters {}".format(port) + cmd = "show queue counters" output = asichost.command(cmd, new_format=True)['stdout_lines'] - - txq = "UC{}".format(queue) + counters = {} for line in output: fields = line.split() - if fields[1] == txq: - return int(fields[2].replace(',', '')) - return -1 + if len(fields) < 3: + continue + port_name = fields[0] + queue_type = fields[1] + if port_name not in counters: + counters[port_name] = {} + if queue_type.startswith(queue_type_prefix): + try: + queue_num = int(queue_type[len(queue_type_prefix):]) + counters[port_name][queue_num] = int(fields[2].replace(',', '')) + except (ValueError, IndexError): + continue + return counters + + +def assert_queue_counter_zero(queue_counters, port_name, queue_start=0, queue_end=6): + for q in range(queue_start, queue_end + 1): + counter_value = queue_counters.get(q, -1) + assert counter_value == 0, ( + "Queue counter for port '{}' queue {} is not zero. Value: {}" + ).format(port_name, q, counter_value) def test_bgp_queues(duthosts, enum_frontend_dut_hostname, enum_asic_index, tbinfo): @@ -37,6 +56,10 @@ def test_bgp_queues(duthosts, enum_frontend_dut_hostname, enum_asic_index, tbinf bgp_facts = duthost.bgp_facts(instance_id=enum_asic_index)['ansible_facts'] mg_facts = asichost.get_extended_minigraph_facts(tbinfo) + all_ports_queue_counters = get_all_ports_queue_counters(asichost, queue_type_prefix="UC") + if not all_ports_queue_counters: + pytest.skip("No queue counters found on the device.") + arp_dict = {} ndp_dict = {} processed_intfs = set() @@ -58,15 +81,16 @@ def test_bgp_queues(duthosts, enum_frontend_dut_hostname, enum_asic_index, tbinf ndp_dict[ip] = iface for k, v in list(bgp_facts['bgp_neighbors'].items()): + # For "peer group" if it's internal it will be "INTERNAL_PEER_V4" or "INTERNAL_PEER_V6" + # or "VOQ_CHASSIS_PEER_V4" or "VOQ_CHASSIS_PEER_V6" for VOQ_CHASSIS + # If it's external it will be "RH_V4", "RH_V6", "AH_V4", "AH_V6", ... + # Skip internal neighbors for VOQ_CHASSIS until BRCM fixes iBGP traffic in 2024011 + if ("INTERNAL" in v["peer group"] or 'VOQ_CHASSIS' in v["peer group"]): + # Skip iBGP neighbors since we only want to verify eBGP + continue # Only consider established bgp sessions if v['state'] == 'established': - # For "peer group" if it's internal it will be "INTERNAL_PEER_V4" or "INTERNAL_PEER_V6" - # or "VOQ_CHASSIS_PEER_V4" or "VOQ_CHASSIS_PEER_V6" for VOQ_CHASSIS - # If it's external it will be "RH_V4", "RH_V6", "AH_V4", "AH_V6", ... - # Skip internal neighbors for VOQ_CHASSIS until BRCM fixes iBGP traffic in 2024011 - if ("INTERNAL" in v["peer group"] or 'VOQ_CHASSIS' in v["peer group"]): - # Skip iBGP neighbors since we only want to verify eBGP - continue + assert (k in arp_dict.keys() or k in ndp_dict.keys()), ( "BGP neighbor IP '{}' not found in either ARP or NDP tables.\n" "- ARP table: {}\n" @@ -79,22 +103,19 @@ def test_bgp_queues(duthosts, enum_frontend_dut_hostname, enum_asic_index, tbinf ifname = ndp_dict[k].split('.', 1)[0] if ifname in processed_intfs: continue - if (ifname.startswith("PortChannel")): + if ifname.startswith("PortChannel"): for port in mg_facts['minigraph_portchannels'][ifname]['members']: logger.info("PortChannel '{}' : port {}".format(ifname, port)) - for q in range(0, 7): - assert (get_queue_counters(asichost, port, q) == 0), ( - ( - "Queue counter for port '{}' queue {} is not zero after clearing queue counters. " - "Counter value: {}" - ).format(port, q, get_queue_counters(asichost, port, q)) - ) + per_port_queue_counters = all_ports_queue_counters.get(port, {}) + if not per_port_queue_counters: + logger.warning("No queue counters found for port '{}'".format(port)) + else: + assert_queue_counter_zero(per_port_queue_counters, port, 0, 6) else: logger.info(ifname) - for q in range(0, 7): - assert (get_queue_counters(asichost, ifname, q) == 0), ( - "Queue counter for interface '{}' queue {} is not zero after clearing queue counters. " - "Counter value: {}" - ).format(ifname, q, get_queue_counters(asichost, ifname, q)) - + per_iface_queue_counters = all_ports_queue_counters.get(ifname, {}) + if not per_iface_queue_counters: + logger.warning("No queue counters found for interface '{}'".format(ifname)) + else: + assert_queue_counter_zero(per_iface_queue_counters, ifname, 0, 6) processed_intfs.add(ifname) From 19e4eb74a005f3b63baf6756d55ce2717238fa70 Mon Sep 17 00:00:00 2001 From: Anton Hryshchuk <76687950+AntonHryshchuk@users.noreply.github.com> Date: Sat, 7 Feb 2026 03:07:35 +0200 Subject: [PATCH 04/43] [Scale|CRM] Optimize test runtime with adaptive polling (#22132) Summary: Replace fixed sleeps with polling and reduce wait times: Add polling helpers: wait_for_crm_counter_update(), wait_for_resource_stabilization() Replace 50s resource waits with adaptive polling Reduce config waits from 10s to 5s (CONFIG_UPDATE_TIME) Reduce cleanup wait from 50s to 20s (SONIC_RES_CLEANUP_UPDATE_TIME) Signed-off-by: AntonHryshchuk --- tests/crm/test_crm.py | 233 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 193 insertions(+), 40 deletions(-) diff --git a/tests/crm/test_crm.py b/tests/crm/test_crm.py index 00b01eac01f..de91c73aa16 100755 --- a/tests/crm/test_crm.py +++ b/tests/crm/test_crm.py @@ -28,6 +28,11 @@ logger = logging.getLogger(__name__) SONIC_RES_UPDATE_TIME = 50 +SONIC_RES_CLEANUP_UPDATE_TIME = 20 +CONFIG_UPDATE_TIME = 5 +FDB_CLEAR_TIMEOUT = 20 +ROUTE_COUNTER_POLL_TIMEOUT = 15 +CRM_COUNTER_TOLERANCE = 2 ACL_TABLE_NAME = "DATAACL" RESTORE_CMDS = {"test_crm_route": [], @@ -126,7 +131,9 @@ def apply_acl_config(duthost, asichost, test_name, collector, entry_num=1): pytest.skip("DATAACL does not exist") # Make sure CRM counters updated - time.sleep(CRM_UPDATE_TIME) + # ACL configuration usually propagates in 3-5 seconds + logger.info("Waiting for ACL configuration to propagate...") + time.sleep(CONFIG_UPDATE_TIME) collector["acl_tbl_key"] = get_acl_tbl_key(asichost) @@ -206,7 +213,9 @@ def apply_fdb_config(duthost, test_name, vlan_id, iface, entry_num): duthost.command(cmd) # Make sure CRM counters updated - time.sleep(CRM_UPDATE_TIME) + # FDB entries typically propagate in 3-5 seconds + logger.info("Waiting for FDB entries to propagate...") + time.sleep(CONFIG_UPDATE_TIME) def get_acl_tbl_key(asichost): @@ -315,6 +324,104 @@ def check_crm_stats(cmd, duthost, origin_crm_stats_used, origin_crm_stats_availa return False +def wait_for_crm_counter_update(cmd, duthost, expected_used, oper_used=">=", timeout=15, interval=1): + """ + Wait for CRM used counter to update by polling. + Raises pytest.fail() if timeout is reached. + """ + last_values = {'used': None, 'avail': None} + + def check_counters(): + try: + crm_used, crm_avail = get_crm_stats(cmd, duthost) + last_values['used'] = crm_used + last_values['avail'] = crm_avail + used_ok = eval(f"{crm_used} {oper_used} {expected_used}") + + if used_ok: + logger.info(f"CRM counter updated: used={crm_used} (expected {oper_used} {expected_used})") + return True + else: + logger.debug(f"Waiting for CRM update: used={crm_used} (expected {oper_used} {expected_used})") + return False + except Exception as e: + logger.debug(f"Error checking CRM stats: {e}") + return False + + pytest_assert(wait_until(timeout, interval, 0, check_counters), + f"CRM counter did not reach expected value within {timeout} seconds. " + f"Expected: used {oper_used} {expected_used}, " + f"Actual: used={last_values['used']}, available={last_values['avail']}") + + +def wait_for_resource_stabilization(get_stats_func, duthost, asic_index, resource_key, + min_expected_used=None, tolerance_percent=5, + timeout=60, interval=5): + """ + Wait for large resource configurations to stabilize by polling. + Raises pytest.fail() if timeout is reached. + """ + logger.info("Waiting for {} resources to stabilize (expecting at least {} used)...".format( + resource_key, min_expected_used if min_expected_used else "N/A")) + + stable_count = 0 + prev_used = None + start_time = time.time() + + def check_stabilized(): + nonlocal stable_count, prev_used + try: + stats = get_stats_func(duthost, asic_index) + current_used = stats[resource_key]['used'] + current_avail = stats[resource_key]['available'] + + logger.debug("{} used: {}, available: {}".format(resource_key, current_used, current_avail)) + + # Check if we've reached minimum expected usage + if min_expected_used and current_used < min_expected_used * (1 - tolerance_percent / 100): + logger.debug("Still adding resources: {} < {} (min expected)".format( + current_used, min_expected_used)) + prev_used = current_used + stable_count = 0 + return False + + # Check if counter is stable (not changing significantly) + if prev_used is not None: + change = abs(current_used - prev_used) + if change <= max(1, current_used * tolerance_percent / 100): + stable_count += 1 + if stable_count >= 2: # Stable for 2 consecutive checks + logger.info("{} resources stabilized at used={}, available={}".format( + resource_key, current_used, current_avail)) + return True + else: + stable_count = 0 + + prev_used = current_used + return False + except Exception as e: + logger.debug("Error checking resource stats: {}".format(e)) + return False + + if wait_until(timeout, interval, 0, check_stabilized): + elapsed = time.time() - start_time + logger.info("{} stabilization took {:.1f} seconds".format(resource_key, elapsed)) + else: + # final stats for error message + try: + final_stats = get_stats_func(duthost, asic_index) + final_used = final_stats[resource_key]['used'] + final_avail = final_stats[resource_key]['available'] + except Exception: + final_used = prev_used + final_avail = "unknown" + + pytest.fail("{} resources did not stabilize within {} seconds. " + "Expected min: {}, Actual: used={}, available={}".format( + resource_key, timeout, min_expected_used if min_expected_used else "N/A", + final_used, final_avail)) + + def generate_neighbors(amount, ip_ver): """ Generate list of IPv4 or IPv6 addresses """ if ip_ver == "4": @@ -453,7 +560,9 @@ def configure_neighbors(amount, interface, ip_ver, asichost, test_name): iface=interface, namespace=asichost.namespace)) # Make sure CRM counters updated - time.sleep(CRM_UPDATE_TIME) + # Neighbor additions typically propagate in 3-5 seconds + logger.info("Waiting for neighbor entries to propagate...") + time.sleep(CONFIG_UPDATE_TIME) def get_entries_num(used, available): @@ -562,8 +671,22 @@ def test_crm_route(duthosts, enum_rand_one_per_hwsku_frontend_hostname, enum_fro if duthost.facts['asic_type'] == 'broadcom': check_available_counters = False - # Make sure CRM counters updated - time.sleep(CRM_UPDATE_TIME) + # Helper function to get current route used counter + def get_route_used(): + stats = get_crm_resources_fdb_and_ip_route(duthost, enum_frontend_asic_index) + return stats[f'ipv{ip_ver}_route']['used'] + + # Make sure CRM counters updated - use polling to wait for route counter to update + logger.info(f"Waiting for route counters to update after adding {total_routes} routes...") + expected_min_used = crm_stats_route_used + total_routes - CRM_COUNTER_TOLERANCE + + def check_route_added(): + return get_route_used() >= expected_min_used + + pytest_assert(wait_until(ROUTE_COUNTER_POLL_TIMEOUT, CRM_POLLING_INTERVAL, 0, check_route_added), + f"Route counter did not update after adding {total_routes} routes " + f"within {ROUTE_COUNTER_POLL_TIMEOUT} seconds. " + f"Expected: used >= {expected_min_used}, Actual: used={get_route_used()}") # Get new ipv[4/6]_route/fdb_entry used and available counter value crm_stats = get_crm_resources_fdb_and_ip_route(duthost, enum_frontend_asic_index) @@ -596,8 +719,17 @@ def test_crm_route(duthosts, enum_rand_one_per_hwsku_frontend_hostname, enum_fro for i in range(total_routes): duthost.command(route_del_cmd.format(asichost.ip_cmd, i, nh_ip)) - # Make sure CRM counters updated - time.sleep(CRM_UPDATE_TIME) + # Make sure CRM counters updated - use polling to wait for route counter to update + logger.info(f"Waiting for route counters to update after deleting {total_routes} routes...") + expected_max_used = crm_stats_route_used - total_routes + CRM_COUNTER_TOLERANCE + + def check_route_deleted(): + return get_route_used() <= expected_max_used + + pytest_assert(wait_until(ROUTE_COUNTER_POLL_TIMEOUT, CRM_POLLING_INTERVAL, 0, check_route_deleted), + f"Route counter did not update after deleting {total_routes} routes " + f"within {ROUTE_COUNTER_POLL_TIMEOUT} seconds. " + f"Expected: used <= {expected_max_used}, Actual: used={get_route_used()}") # Get new ipv[4/6]_route/fdb_entry used and available counter value crm_stats = get_crm_resources_fdb_and_ip_route(duthost, enum_frontend_asic_index) @@ -644,11 +776,14 @@ def test_crm_route(duthosts, enum_rand_one_per_hwsku_frontend_hostname, enum_fro duthost.shell(add_routes_template.render(routes_list=routes_list, interface=crm_interface[0], namespace=asichost.namespace)) - logger.info("Waiting {} seconds for SONiC to update resources...".format(SONIC_RES_UPDATE_TIME)) - # Make sure SONIC configure expected entries - time.sleep(SONIC_RES_UPDATE_TIME) + # Wait for resources to stabilize using adaptive polling + expected_routes = new_crm_stats_route_used + routes_num + logger.info("Waiting for {} route resources to stabilize".format(routes_num)) + wait_for_resource_stabilization(get_crm_resources_fdb_and_ip_route, duthost, + enum_frontend_asic_index, 'ipv{}_route'.format(ip_ver), + min_expected_used=expected_routes, timeout=60, interval=5) - RESTORE_CMDS["wait"] = SONIC_RES_UPDATE_TIME + RESTORE_CMDS["wait"] = SONIC_RES_CLEANUP_UPDATE_TIME # Verify thresholds for "IPv[4/6] route" CRM resource # Get "crm_stats_ipv[4/6]_route" used and available counter value @@ -770,11 +905,14 @@ def test_crm_nexthop(duthosts, enum_rand_one_per_hwsku_frontend_hostname, configure_neighbors(amount=neighbours_num, interface=crm_interface[0], ip_ver=ip_ver, asichost=asichost, test_name="test_crm_nexthop") - logger.info("Waiting {} seconds for SONiC to update resources...".format(SONIC_RES_UPDATE_TIME)) - # Make sure SONIC configure expected entries - time.sleep(SONIC_RES_UPDATE_TIME) + # Wait for nexthop resources to stabilize using polling + expected_nexthop_used = new_crm_stats_nexthop_used + neighbours_num - CRM_COUNTER_TOLERANCE + logger.info("Waiting for {} nexthop resources to stabilize (expecting ~{} total used)...".format( + neighbours_num, expected_nexthop_used)) + wait_for_crm_counter_update(get_nexthop_stats, duthost, expected_used=expected_nexthop_used, + oper_used=">=", timeout=60, interval=5) - RESTORE_CMDS["wait"] = SONIC_RES_UPDATE_TIME + RESTORE_CMDS["wait"] = SONIC_RES_CLEANUP_UPDATE_TIME # Verify thresholds for "IPv[4/6] nexthop" CRM resource verify_thresholds(duthost, asichost, crm_cli_res="ipv{ip_ver} nexthop".format(ip_ver=ip_ver), @@ -853,11 +991,13 @@ def test_crm_neighbor(duthosts, enum_rand_one_per_hwsku_frontend_hostname, configure_neighbors(amount=neighbours_num, interface=crm_interface[0], ip_ver=ip_ver, asichost=asichost, test_name="test_crm_neighbor") - logger.info("Waiting {} seconds for SONiC to update resources...".format(SONIC_RES_UPDATE_TIME)) - # Make sure SONIC configure expected entries - time.sleep(SONIC_RES_UPDATE_TIME) + # Wait for neighbor resources to stabilize using polling + expected_neighbor_used = new_crm_stats_neighbor_used + neighbours_num - CRM_COUNTER_TOLERANCE + logger.info("Waiting for {} neighbor resources to stabilize".format(neighbours_num)) + wait_for_crm_counter_update(get_neighbor_stats, duthost, expected_used=expected_neighbor_used, + oper_used=">=", timeout=60, interval=5) - RESTORE_CMDS["wait"] = SONIC_RES_UPDATE_TIME + RESTORE_CMDS["wait"] = SONIC_RES_CLEANUP_UPDATE_TIME # Verify thresholds for "IPv[4/6] neighbor" CRM resource verify_thresholds(duthost, asichost, crm_cli_res="ipv{ip_ver} neighbor".format(ip_ver=ip_ver), @@ -976,11 +1116,13 @@ def test_crm_nexthop_group(duthosts, enum_rand_one_per_hwsku_frontend_hostname, asichost=asichost, test_name="test_crm_nexthop_group", chunk_size=chunk_size) - logger.info("Waiting {} seconds for SONiC to update resources...".format(SONIC_RES_UPDATE_TIME)) - # Make sure SONIC configure expected entries - time.sleep(SONIC_RES_UPDATE_TIME) + # Wait for nexthop group resources to stabilize using polling + expected_nhg_used = new_nexthop_group_used + nexthop_group_num - CRM_COUNTER_TOLERANCE + logger.info("Waiting for {} nexthop group resources to stabilize".format(nexthop_group_num)) + wait_for_crm_counter_update(get_nexthop_group_stats, duthost, expected_used=expected_nhg_used, + oper_used=">=", timeout=60, interval=5) - RESTORE_CMDS["wait"] = SONIC_RES_UPDATE_TIME + RESTORE_CMDS["wait"] = SONIC_RES_CLEANUP_UPDATE_TIME verify_thresholds(duthost, asichost, crm_cli_res=redis_threshold, crm_cmd=get_nexthop_group_stats) @@ -1058,8 +1200,11 @@ def verify_acl_crm_stats(duthost, asichost, enum_rand_one_per_hwsku_frontend_hos logger.info(f"Next hop group number: {nexthop_group_num}") apply_acl_config(duthost, asichost, "test_acl_entry", asic_collector, nexthop_group_num) - # Make sure SONIC configure expected entries - time.sleep(SONIC_RES_UPDATE_TIME) + # Wait for ACL entry resources to stabilize using polling + expected_acl_used = new_crm_stats_acl_entry_used + nexthop_group_num - CRM_COUNTER_TOLERANCE + logger.info("Waiting for {} ACL entry resources to stabilize".format(nexthop_group_num)) + wait_for_crm_counter_update(get_acl_entry_stats, duthost, expected_used=expected_acl_used, + oper_used=">=", timeout=60, interval=5) # Verify thresholds for "ACL entry" CRM resource verify_thresholds(duthost, asichost, crm_cli_res="acl group entry", crm_cmd=get_acl_entry_stats) @@ -1179,12 +1324,14 @@ def test_acl_counter(duthosts, enum_rand_one_per_hwsku_frontend_hostname, enum_f crm_stats_acl_entry_available".format(db_cli=asichost.sonic_db_cli, acl_tbl_key=acl_tbl_key) _, available_acl_entry_num = get_crm_stats(get_acl_entry_stats, duthost) # The number we can applied is limited to available_acl_entry_num - apply_acl_config(duthost, asichost, "test_acl_counter", asic_collector, - min(needed_acl_counter_num, available_acl_entry_num)) + actual_acl_count = min(needed_acl_counter_num, available_acl_entry_num) + apply_acl_config(duthost, asichost, "test_acl_counter", asic_collector, actual_acl_count) - logger.info("Waiting {} seconds for SONiC to update resources...".format(SONIC_RES_UPDATE_TIME)) - # Make sure SONIC configure expected entries - time.sleep(SONIC_RES_UPDATE_TIME) + # Wait for ACL counter resources to stabilize using polling + expected_acl_counter_used = new_crm_stats_acl_counter_used + actual_acl_count - CRM_COUNTER_TOLERANCE + logger.info("Waiting for {} ACL counter resources to stabilize".format(actual_acl_count)) + wait_for_crm_counter_update(get_acl_counter_stats, duthost, expected_used=expected_acl_counter_used, + oper_used=">=", timeout=60, interval=5) new_crm_stats_acl_counter_used, new_crm_stats_acl_counter_available = \ get_crm_stats(get_acl_counter_stats, duthost) @@ -1298,11 +1445,14 @@ def test_crm_fdb_entry(duthosts, enum_rand_one_per_hwsku_frontend_hostname, enum fdb_entries_num = get_entries_num(new_crm_stats_fdb_entry_used, new_crm_stats_fdb_entry_available) # Generate FDB json file with 'fdb_entries_num' entries and apply it on DUT apply_fdb_config(duthost, "test_crm_fdb_entry", vlan_id, iface, fdb_entries_num) - logger.info("Waiting {} seconds for SONiC to update resources...".format(SONIC_RES_UPDATE_TIME)) - # Make sure SONIC configure expected entries - time.sleep(SONIC_RES_UPDATE_TIME) - RESTORE_CMDS["wait"] = SONIC_RES_UPDATE_TIME + # Wait for FDB entry resources to stabilize using polling + expected_fdb_used = new_crm_stats_fdb_entry_used + fdb_entries_num - CRM_COUNTER_TOLERANCE + logger.info("Waiting for {} FDB entry resources to stabilize".format(fdb_entries_num)) + wait_for_crm_counter_update(get_fdb_stats, duthost, expected_used=expected_fdb_used, + oper_used=">=", timeout=60, interval=5) + + RESTORE_CMDS["wait"] = SONIC_RES_CLEANUP_UPDATE_TIME # Verify thresholds for "FDB entry" CRM resource verify_thresholds(duthost, asichost, crm_cli_res="fdb", crm_cmd=get_fdb_stats) @@ -1311,16 +1461,19 @@ def test_crm_fdb_entry(duthosts, enum_rand_one_per_hwsku_frontend_hostname, enum cmd = "fdbclear" duthost.command(cmd) - # Make sure CRM counters updated - time.sleep(CRM_UPDATE_TIME) - # Timeout for asyc fdb clear - FDB_CLEAR_TIMEOUT = 10 - while FDB_CLEAR_TIMEOUT > 0: + # Make sure CRM counters updated - use polling + # Wait for FDB clear to complete with async polling + logger.info("Waiting for FDB clear to complete...") + fdb_clear_timeout = FDB_CLEAR_TIMEOUT + new_crm_stats_fdb_entry_used = None + new_crm_stats_fdb_entry_available = None + while fdb_clear_timeout > 0: # Get new "crm_stats_fdb_entry" used and available counter value new_crm_stats_fdb_entry_used, new_crm_stats_fdb_entry_available = get_crm_stats(get_fdb_stats, duthost) if new_crm_stats_fdb_entry_used == 0: + logger.debug("FDB cleared successfully") break - FDB_CLEAR_TIMEOUT -= CRM_POLLING_INTERVAL + fdb_clear_timeout -= CRM_POLLING_INTERVAL time.sleep(CRM_POLLING_INTERVAL) # Verify "crm_stats_fdb_entry_used" counter was decremented From 7a3bf3a77b75d1399d35526cfdea50809266cc1d Mon Sep 17 00:00:00 2001 From: Cong Hou <97947969+congh-nvidia@users.noreply.github.com> Date: Sat, 7 Feb 2026 09:17:28 +0800 Subject: [PATCH 05/43] [Generic hash] Update the generic hash test to reduce runtime for scale topos. (#22169) What is the motivation for this PR? The generic hash test run time depends on the number of egress ports in the topology. When running on the scale topology like t0-isolated-d256u256s2, it takes more than 6 hours. This change is to optimize the runtime. The regular topologies also benefit from this. How did you do it? Decrease the total number of sent packets based on the number of egress ports. Meanwhile relax the threshold for balancing check also based on the number of egress port. Skip the lag related cases on topologies which have no lags. Skip some test cases when there is not enough test ports or the variations in the tested field are too few. For example when the hash field is IP_PROTOCOL, there are total 256 values, it not enough to hash to all 256 next hops perfectly. Improve the test result output. Add asic db route check in reboot test case. After reboot/reload, it takes time for the default route to be installed in the ASIC, only checking the kernel route is not enough for scale topos. How did you verify/test it? Run the test on SN5640 with t0-isolated-d256u256s2 topo. The total runtime is about 1.5 ~ 2 hours now. Signed-off-by: Cong Hou --- .../files/ptftests/py3/generic_hash_test.py | 71 ++++++++++----- tests/hash/generic_hash_helper.py | 91 ++++++++++++++++++- tests/hash/test_generic_hash.py | 8 +- 3 files changed, 141 insertions(+), 29 deletions(-) diff --git a/ansible/roles/test/files/ptftests/py3/generic_hash_test.py b/ansible/roles/test/files/ptftests/py3/generic_hash_test.py index 824bfb824c0..a320aacc957 100644 --- a/ansible/roles/test/files/ptftests/py3/generic_hash_test.py +++ b/ansible/roles/test/files/ptftests/py3/generic_hash_test.py @@ -22,8 +22,8 @@ class GenericHashTest(BaseTest): # --------------------------------------------------------------------- # Class variables # --------------------------------------------------------------------- - DEFAULT_BALANCING_RANGE = 0.25 - BALANCING_TEST_TIMES = 625 + DEFAULT_BALANCING_RANGE = 0.3 + BALANCING_TEST_TIMES = 480 VXLAN_PORT = 4789 VXLAN_VNI = 20001 NVGRE_TNI = 20001 @@ -206,12 +206,12 @@ def _get_single_layer_packet(): if self.hash_field == 'ETHERTYPE': pkt['Ether'].type = random.choice(range(self.ethertype_range[0], self.ethertype_range[1])) if not self.is_l2_test: - pkt_summary = f"{self.ipver} packet with src_mac:{src_mac}, dst_mac:{dst_mac}, src_ip:{src_ip}, " \ - f"dst_ip:{dst_ip}, src_port:{src_port}, dst_port: {dst_port}, " \ - f"ip_protocol:{_get_pkt_ip_protocol(pkt)}" + pkt_summary = f"{self.ipver} packet with src_mac: {src_mac}, dst_mac: {dst_mac}, src_ip: {src_ip}, " \ + f"dst_ip: {dst_ip}, src_port: {src_port}, dst_port: {dst_port}, " \ + f"ip_protocol: {_get_pkt_ip_protocol(pkt)}" else: - pkt_summary = f"Ethernet packet with src_mac:{src_mac}, dst_mac:{dst_mac}, " \ - f"ether_type:{hex(pkt['Ether'].type)}, vlan_id:{vlan_id if vlan_id != 0 else 'N/A'}" + pkt_summary = f"Ethernet packet with src_mac: {src_mac}, dst_mac: {dst_mac}, " \ + f"ether_type: {hex(pkt['Ether'].type)}, vlan_id: {vlan_id if vlan_id != 0 else 'N/A'}" if self.hash_field == 'IPV6_FLOW_LABEL': pkt_summary += f", ipv6 flow label: {flow_label} " @@ -244,10 +244,10 @@ def _get_ipinip_packet(): masked_expected_pkt.set_do_not_care_packet(scapy.Ether, "dst") masked_expected_pkt.set_do_not_care_packet(scapy.Ether, "src") masked_expected_pkt.set_do_not_care_packet(scapy.IPv6, "hlim") - pkt_summary = f"{self.ipver} ipinip packet with src_ip:{src_ip}, dst_ip:{dst_ip}, " \ - f"ip_protocol:{_get_pkt_ip_protocol(pkt)}, inner_ipver:{self.inner_ipver}, " \ - f"inner_src_ip:{inner_src_ip}, inner_dst_ip:{inner_dst_ip}, inner_src_port:{src_port}," \ - f" inner_dst_port:{dst_port}, inner_ip_protocol:{_get_pkt_ip_protocol(inner_pkt)}" + pkt_summary = f"{self.ipver} ipinip packet with src_ip: {src_ip}, dst_ip: {dst_ip}, " \ + f"ip_protocol: {_get_pkt_ip_protocol(pkt)}, inner_ipver: {self.inner_ipver}, " \ + f"inner_src_ip: {inner_src_ip}, inner_dst_ip: {inner_dst_ip}, inner_src_port: {src_port}, " \ + f" inner_dst_port: {dst_port}, inner_ip_protocol: {_get_pkt_ip_protocol(inner_pkt)}" return pkt, masked_expected_pkt, pkt_summary def _get_vxlan_packet(): @@ -286,11 +286,12 @@ def _get_vxlan_packet(): masked_expected_pkt.set_do_not_care_packet(scapy.Ether, "dst") masked_expected_pkt.set_do_not_care_packet(scapy.Ether, "src") masked_expected_pkt.set_do_not_care_packet(scapy.IPv6, "hlim") - pkt_summary = f"{self.ipver} vxlan packet with src_ip:{src_ip}, dst_ip:{dst_ip}, " \ - f"src_port:{self.L4_SRC_PORT}, dst_port: {self.vxlan_port}, ip_protocol:{_get_pkt_ip_protocol(pkt)}, " \ - f"inner_ipver:{self.inner_ipver}, inner_src_ip:{inner_src_ip}, inner_dst_ip:{inner_dst_ip}, " \ - f"inner_src_port:{src_port}, inner_dst_port:{dst_port}, " \ - f"inner_ip_protocol:{_get_pkt_ip_protocol(inner_pkt)}" + pkt_summary = f"{self.ipver} vxlan packet with src_ip: {src_ip}, dst_ip: {dst_ip}, " \ + f"src_port: {self.L4_SRC_PORT}, dst_port: {self.vxlan_port}, " \ + f"ip_protocol: {_get_pkt_ip_protocol(pkt)}, " \ + f"inner_ipver: {self.inner_ipver}, inner_src_ip: {inner_src_ip}, inner_dst_ip: {inner_dst_ip}, " \ + f"inner_src_port: {src_port}, inner_dst_port: {dst_port}, " \ + f"inner_ip_protocol: {_get_pkt_ip_protocol(inner_pkt)}" return pkt, masked_expected_pkt, pkt_summary def _get_nvgre_packet(): @@ -325,10 +326,10 @@ def _get_nvgre_packet(): masked_expected_pkt.set_do_not_care_packet(scapy.Ether, "dst") masked_expected_pkt.set_do_not_care_packet(scapy.Ether, "src") masked_expected_pkt.set_do_not_care_packet(scapy.IPv6, "hlim") - pkt_summary = f"{self.ipver} nvgre packet with src_ip:{src_ip}, dst_ip:{dst_ip}, " \ - f"ip_protocol:{_get_pkt_ip_protocol(pkt)}, inner_ipver:{self.inner_ipver}, " \ - f"inner_src_ip:{inner_src_ip}, inner_dst_ip:{inner_dst_ip}, inner_src_port:{src_port}, " \ - f"inner_dst_port:{dst_port}, inner_ip_protocol:{_get_pkt_ip_protocol(inner_pkt)}" + pkt_summary = f"{self.ipver} nvgre packet with src_ip: {src_ip}, dst_ip: {dst_ip}, " \ + f"ip_protocol: {_get_pkt_ip_protocol(pkt)}, inner_ipver: {self.inner_ipver}, " \ + f"inner_src_ip: {inner_src_ip}, inner_dst_ip: {inner_dst_ip}, inner_src_port: {src_port}, " \ + f"inner_dst_port: {dst_port}, inner_ip_protocol: {_get_pkt_ip_protocol(inner_pkt)}" return pkt, masked_expected_pkt, pkt_summary src_mac = _get_src_mac() @@ -396,13 +397,14 @@ def check_ip_route(self, pkt, masked_expected_pkt, sending_port): port_index, received = testutils.verify_packet_any_port( self, masked_expected_pkt, self.expected_port_list, timeout=0.1) except AssertionError: - logging.error("Traffic wasn't sent successfully, trying again") + logging.warning("Packet wasn't received successfully, trying again") logging.info(f"Expected packet: {masked_expected_pkt}") for _ in range(5): testutils.send_packet(self, sending_port, pkt, count=1) time.sleep(0.1) port_index, received = testutils.verify_packet_any_port( self, masked_expected_pkt, self.expected_port_list, timeout=1) + logging.info("Packet was received successfully after retry.") # The port_index is the index of expected_port_list, need to convert it to the ptf port index return self.expected_port_list[port_index], received @@ -511,6 +513,31 @@ def _check_only_lag_hash_balancing(): elif not self.ecmp_hash and self.lag_hash: _check_only_lag_hash_balancing() + def print_result_summary(self, hit_count_map): + total_receiving_ports = len(hit_count_map) + total_received_packets = sum(hit_count_map.values()) + average_packets = total_received_packets / total_receiving_ports + min_received_packets = min(hit_count_map.values()) + max_received_packets = max(hit_count_map.values()) + min_received_ports = [port for port, count in hit_count_map.items() if count == min_received_packets] + max_received_ports = [port for port, count in hit_count_map.items() if count == max_received_packets] + expected_total_hit_cnt = self.balancing_test_times * len(self.expected_port_list) + expected_hit_cnt_per_port = self.balancing_test_times + if self.ecmp_hash and not self.lag_hash: + expected_hit_cnt_per_port = expected_total_hit_cnt / len(self.expected_port_groups) + elif not self.ecmp_hash and self.lag_hash: + expected_hit_cnt_per_port = expected_total_hit_cnt / len(self.expected_port_groups[0]) + max_deviation = max(abs(expected_hit_cnt_per_port - min_received_packets), + abs(expected_hit_cnt_per_port - max_received_packets)) + max_deviation_percentage = str(max_deviation / expected_hit_cnt_per_port * 100) + "%" + logging.info(f"\nTotal number of receiving ports: {total_receiving_ports}\n" + f"Total packet count: {total_received_packets}\n" + f"Average packet count: {average_packets}\n" + f"Min packets on a port: {min_received_packets}(Ports: {min_received_ports})\n" + f"Max packets on a port: {max_received_packets}(Ports: {max_received_ports})\n" + f"Max deviation: {max_deviation}\n" + f"Max deviation percentage: {max_deviation_percentage}\n") + def runTest(self): logging.info("=============Test Start==============") hit_count_map = {} @@ -544,9 +571,9 @@ def runTest(self): logging.info("Received packet at index {}: {}".format( str(matched_port), re.sub(r"(?<=\w)(?=(?:\w\w)+$)", ' ', received.hex()))) - time.sleep(0.02) hit_count_map[matched_port] = hit_count_map.get(matched_port, 0) + 1 logging.info(f"hash_field={self.hash_field}, hit count map: {hit_count_map}") + self.print_result_summary(hit_count_map) # Check if the traffic is properly balanced self.check_balancing(hit_count_map) diff --git a/tests/hash/generic_hash_helper.py b/tests/hash/generic_hash_helper.py index a8f6e8ee77b..7211e04df3d 100644 --- a/tests/hash/generic_hash_helper.py +++ b/tests/hash/generic_hash_helper.py @@ -4,6 +4,7 @@ import logging import pytest import ipaddress +import re from tests.common.helpers.assertions import pytest_assert from tests.common.utilities import wait_until @@ -67,8 +68,8 @@ l2_ports = set() vlans_to_remove = [] interfaces_to_startup = [] -balancing_test_times = 480 -balancing_range = 0.25 +base_balancing_test_times = 480 +base_balancing_range = 0.3 balancing_range_in_port = 0.8 vxlan_ecmp_utils = VxLAN_Ecmp_Utils() vxlan_port_list = [13330, 4789] @@ -94,6 +95,33 @@ def skip_vs_setups(rand_selected_dut): pytest.skip("Generic hash test only runs on physical setups.") +@pytest.fixture(scope="function", autouse=True) +def skip_lag_tests_on_no_lag_topos(request, rand_selected_dut): + if "lag" in request.node.name and \ + "PORTCHANNEL" not in rand_selected_dut.get_running_config_facts(): + pytest.skip("The topology doesn't have portchannels, skip the lag test cases.") + + +@pytest.fixture(scope="function", autouse=True) +def skip_tests_on_isolated_topos(request, tbinfo): + if 'isolated' in tbinfo['topo']['name']: + uplink_count = re.search(r'u(\d+)', tbinfo['topo']['name']) + downlink_count = re.search(r'd(\d+)', tbinfo['topo']['name']) + if uplink_count: + uplink_count = int(uplink_count.group(1)) + else: + pytest.skip("Isolated topologies with no uplinks is not supported by the test.") + if downlink_count: + downlink_count = int(downlink_count.group(1)) + else: + pytest.skip("Isolated topologies with no downlinks is not supported by the test.") + if uplink_count > 32 and "IP_PROTOCOL" in request.node.name: + pytest.skip("IP_PROTOCOL hash field is not supported on topos with more than 32 uplinks.") + if downlink_count / uplink_count < 2 and "IN_PORT" in request.node.name: + pytest.skip("At least twice the number of downlinks compared" + " to uplinks is required for IN_PORT hash test.") + + @pytest.fixture(scope="module") def mg_facts(rand_selected_dut, tbinfo): """ Fixture to get the extended minigraph facts """ @@ -300,6 +328,27 @@ def check_default_route(duthost, expected_nexthops): return set(nexthops) == set(expected_nexthops) +def check_default_route_asic_db(duthost): + """ + Check the default route exists in the asic db. + Args: + duthost (AnsibleHost): Device Under Test (DUT) + Returns: + True if the default route and nexthop id exist in the asic db. + """ + logger.info("Check if the default route exists in the asic db.") + route_entry = duthost.shell( + 'redis-cli -n 1 keys "*ASIC_STATE:SAI_OBJECT_TYPE_ROUTE_ENTRY:*0.0.0.0/0*"', + module_ignore_errors=True)["stdout"] + if not route_entry: + return False + route_entry_content = duthost.shell(f"redis-cli -n 1 hgetall '{route_entry}'")["stdout"] + if "SAI_ROUTE_ENTRY_ATTR_NEXT_HOP_ID" in route_entry_content: + return True + else: + return False + + def get_ptf_port_indices(mg_facts, downlink_interfaces, uplink_interfaces): """ Get the ptf port indices for the interfaces under test. @@ -317,10 +366,10 @@ def get_ptf_port_indices(mg_facts, downlink_interfaces, uplink_interfaces): for interface in downlink_interfaces: sending_ports.append(mg_facts['minigraph_ptf_indices'][interface]) expected_port_groups = [] - for index, portchannel in enumerate(uplink_interfaces.keys()): + for index, interface in enumerate(uplink_interfaces.keys()): expected_port_groups.append([]) - for interface in uplink_interfaces[portchannel]: - expected_port_groups[index].append(mg_facts['minigraph_ptf_indices'][interface]) + for port in uplink_interfaces[interface]: + expected_port_groups[index].append(mg_facts['minigraph_ptf_indices'][port]) expected_port_groups[index].sort() return sending_ports, expected_port_groups @@ -441,6 +490,33 @@ def get_interfaces_for_test(duthost, mg_facts, hash_field): return uplink_interfaces, downlink_interfaces +def get_updated_balancing_test_times_and_range(uplink_interfaces): + """ + Update the balancing test times and range based on the number of uplink interfaces. + When the number of egress ports gets larger, the variations in some of the fields + may be not enough to get a perfect balancing result. + For example the IP_PROTOCOL field has only 256 values and VLAN_ID field has only 4096 values. + So we relax the threshold based on the number of egress ports. + Args: + uplink_interfaces: a dictionary of the uplink interfaces + Returns: + the new balancing test times and range + """ + uplink_physicalport_count = sum(len(members) for members in uplink_interfaces.values()) + balancing_test_times = base_balancing_test_times + balancing_range = base_balancing_range + if uplink_physicalport_count >= 32 and uplink_physicalport_count < 64: + balancing_test_times = int(base_balancing_test_times * 0.75) + balancing_range = round(balancing_range + 0.05, 2) + elif uplink_physicalport_count >= 64 and uplink_physicalport_count < 128: + balancing_test_times = int(base_balancing_test_times * 0.5) + balancing_range = round(balancing_range + 0.1, 2) + elif uplink_physicalport_count >= 128: + balancing_test_times = int(base_balancing_test_times * 0.25) + balancing_range = round(balancing_range + 0.15, 2) + return balancing_test_times, balancing_range + + def get_asic_type(request): metadata = get_testbed_metadata(request) if metadata is None: @@ -706,6 +782,11 @@ def generate_test_params(duthost, tbinfo, mg_facts, hash_field, ipver, inner_ipv dest_mac = get_vlan_intf_mac(duthost) else: dest_mac = duthost.facts['router_mac'] + # Update the balancing_test_times and balancing_range based on the number of uplink interfaces + # The test will run too long time if the balancing_test_times is big when + # the number of uplink interfaces is large + # Meanwhile relax the balancing_range to make sure the test is still stable. + balancing_test_times, balancing_range = get_updated_balancing_test_times_and_range(uplink_interfaces) ptf_params = {"router_mac": dest_mac, "sending_ports": ptf_sending_ports, "expected_port_groups": ptf_expected_port_groups, diff --git a/tests/hash/test_generic_hash.py b/tests/hash/test_generic_hash.py index bd82286266a..e4e679048ba 100644 --- a/tests/hash/test_generic_hash.py +++ b/tests/hash/test_generic_hash.py @@ -8,10 +8,12 @@ get_reboot_type_from_option, HASH_CAPABILITIES, check_global_hash_config, startup_interface, \ get_interfaces_for_test, get_ptf_port_indices, check_default_route, generate_test_params, flap_interfaces, \ PTF_QLEN, remove_ip_interface_and_config_vlan, config_custom_vxlan_port, shutdown_interface, \ - remove_add_portchannel_member, get_hash_algorithm_from_option, check_global_hash_algorithm, get_diff_hash_algorithm + remove_add_portchannel_member, get_hash_algorithm_from_option, check_global_hash_algorithm, \ + get_diff_hash_algorithm, check_default_route_asic_db from generic_hash_helper import restore_configuration, reload, global_hash_capabilities, restore_interfaces # noqa:F401 from generic_hash_helper import mg_facts, restore_init_hash_config, restore_vxlan_port, \ - get_supported_hash_algorithms, toggle_all_simulator_ports_to_upper_tor # noqa:F401 + get_supported_hash_algorithms, toggle_all_simulator_ports_to_upper_tor, skip_lag_tests_on_no_lag_topos # noqa:F401 +from generic_hash_helper import skip_tests_on_isolated_topos # noqa:F401 from tests.common.utilities import wait_until from tests.ptf_runner import ptf_runner from tests.common.fixtures.ptfhost_utils import copy_ptftests_directory # noqa: F401 @@ -708,6 +710,8 @@ def test_reboot(rand_selected_dut, tbinfo, ptfhost, localhost, fine_params, mg_f with allure.step('Check the route is established'): pytest_assert(wait_until(60, 10, 0, check_default_route, rand_selected_dut, uplink_interfaces.keys()), "The default route is not established after the cold reboot.") + pytest_assert(wait_until(120, 10, 0, check_default_route_asic_db, rand_selected_dut), + 'The default route are not installed to the asic db.') with allure.step('Start the ptf test, send traffic and check the balancing'): ptf_runner( ptfhost, From 18501586eb8a42f10ee0b503d2721ab6e1c8be7b Mon Sep 17 00:00:00 2001 From: mramezani95 Date: Sat, 7 Feb 2026 21:06:56 -0800 Subject: [PATCH 06/43] Adding support for restapi containers in `container_upgrade` tests (#22287) Description of PR Changed container_upgrade tests to support restapi containers (restapi, restapi_watchdog, and restapi_sidecar). Summary: Microsoft ADO ID: 36672179 Approach What is the motivation for this PR? Adding support for restapi containers in container_upgrade tests so that we can run restapi tests using the same container images on different OS versions. How did you do it? Added instructions for creating restapi containers and running restapi tests in container_upgrade. How did you verify/test it? Tested on a T1 switch running 202505. The restapi tests passed. --- tests/container_upgrade/container_upgrade_helper.py | 3 +++ tests/container_upgrade/parameters.json | 9 +++++++++ tests/container_upgrade/restapi_testcases.json | 4 ++++ tests/restapi/helper.py | 4 ++-- 4 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 tests/container_upgrade/restapi_testcases.json diff --git a/tests/container_upgrade/container_upgrade_helper.py b/tests/container_upgrade/container_upgrade_helper.py index e49eca9683e..dcfea0081dd 100644 --- a/tests/container_upgrade/container_upgrade_helper.py +++ b/tests/container_upgrade/container_upgrade_helper.py @@ -28,6 +28,9 @@ "docker-gnmi-watchdog": "gnmi_watchdog", "docker-sonic-bmp": "bmp", "docker-bmp-watchdog": "bmp_watchdog", + "docker-sonic-restapi": "restapi", + "docker-restapi-watchdog": "restapi_watchdog", + "docker-restapi-sidecar": "restapi_sidecar", } diff --git a/tests/container_upgrade/parameters.json b/tests/container_upgrade/parameters.json index bbca808f852..e90439696c6 100644 --- a/tests/container_upgrade/parameters.json +++ b/tests/container_upgrade/parameters.json @@ -13,5 +13,14 @@ }, "docker-bmp-watchdog": { "parameters": "--pid=host --net=host -v /etc/localtime:/etc/localtime:ro -v /etc/sonic:/etc/sonic:ro" + }, + "docker-sonic-restapi": { + "parameters": "--net=host -v /var/run/redis/redis.sock:/var/run/redis/redis.sock -v /etc/sonic/credentials:/etc/sonic/credentials:ro -v /etc/localtime:/etc/localtime:ro" + }, + "docker-restapi-watchdog": { + "parameters": "--net=host -v /etc/localtime:/etc/localtime:ro" + }, + "docker-restapi-sidecar": { + "parameters": "--privileged --pid=host --net=host --uts=host --ipc=host -v /etc/sonic:/etc/sonic:ro -v /usr/bin/docker:/usr/bin/docker:ro -v /var/run/docker.sock:/var/run/docker.sock -v /:/hostroot:ro -e DOCKER_BIN=/usr/bin/docker" } } diff --git a/tests/container_upgrade/restapi_testcases.json b/tests/container_upgrade/restapi_testcases.json new file mode 100644 index 00000000000..6f50fa0091c --- /dev/null +++ b/tests/container_upgrade/restapi_testcases.json @@ -0,0 +1,4 @@ +{ + "restapi/test_restapi_client_cert_auth.py": 0, + "restapi/test_restapi_vxlan_ecmp.py": 0 +} diff --git a/tests/restapi/helper.py b/tests/restapi/helper.py index ac8e46c5260..e72ae568b1d 100644 --- a/tests/restapi/helper.py +++ b/tests/restapi/helper.py @@ -34,7 +34,7 @@ def apply_cert_config(duthost): time.sleep(5) # Restart RESTAPI server with the updated config - dut_command = "sudo systemctl restart restapi" + dut_command = "docker restart restapi" duthost.shell(dut_command) time.sleep(RESTAPI_SERVER_START_WAIT_TIME) @@ -50,6 +50,6 @@ def set_trusted_client_cert_subject_name(duthost, new_subject_name): time.sleep(5) # Restart RESTAPI server with the updated config - dut_command = "sudo systemctl restart restapi" + dut_command = "docker restart restapi" duthost.shell(dut_command) time.sleep(RESTAPI_SERVER_START_WAIT_TIME) From 4a0c5c1bc6d0ab3069cd0837eebf8d10b453fcd7 Mon Sep 17 00:00:00 2001 From: Dayou Liu <113053330+dayouliu1@users.noreply.github.com> Date: Sun, 8 Feb 2026 07:26:58 -0800 Subject: [PATCH 07/43] Fix test_bgp_suppress_fib.py flakiness on scale topos (#21843) What is the motivation for this PR? Fix test_bgp_suppress_fib.py flakiness on scale topos. Extension of BULK_TRAFFIC_WAIT_TIME and ignoring malformed BGP route packets allows these tests to pass consistently on Arista t1 isolated DUTs How did you do it? How did you verify/test it? Tested on T0 and T1 isolated Arista DUTs --- tests/bgp/test_bgp_suppress_fib.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/bgp/test_bgp_suppress_fib.py b/tests/bgp/test_bgp_suppress_fib.py index a2acfec5874..f967a51fae4 100644 --- a/tests/bgp/test_bgp_suppress_fib.py +++ b/tests/bgp/test_bgp_suppress_fib.py @@ -63,7 +63,7 @@ FUNCTION = "function" STRESS = "stress" TRAFFIC_WAIT_TIME = 0.1 -BULK_TRAFFIC_WAIT_TIME = 0.004 +BULK_TRAFFIC_WAIT_TIME = 0.01 BGP_ROUTE_FLAP_TIMES = 5 UPDATE_WITHDRAW_THRESHOLD = 5 # consider the switch with low power cpu and a lot of bgp neighbors @@ -538,10 +538,14 @@ def parse_time_stamp(bgp_packets, ipv4_route_list, ipv6_route_list): layer = bgp_updates[i].getlayer(bgp.BGPUpdate, nb=layer_index) if layer.nlri: for route in layer.nlri: + if not hasattr(route, 'prefix'): # skip malformed/segmented routes + continue if route.prefix in ipv4_route_list: update_time_stamp(announce_prefix_time_stamp, route.prefix, bgp_packets[i].time) if layer.withdrawn_routes: for route in layer.withdrawn_routes: + if not hasattr(route, 'prefix'): # skip malformed/segmented routes + continue if route.prefix in ipv4_route_list: update_time_stamp(withdraw_prefix_time_stamp, route.prefix, bgp_packets[i].time) layer_index += 1 From 31b0386b6e713513ec10906ae2a222c594445636 Mon Sep 17 00:00:00 2001 From: Chris <156943338+ccroy-arista@users.noreply.github.com> Date: Sun, 8 Feb 2026 07:28:47 -0800 Subject: [PATCH 08/43] Fix invalid method call in conftest.py (#22158) * Fix AttributeError in conftest.py In the ptfhosts pytest fixture in conftest.py, a method "_hosts.apend" appears, which is a typo corrected by this change to "_hosts.append". Signed-off-by: Christopher Croy * Fix flake8 lint indentation error Signed-off-by: Christopher Croy --------- Signed-off-by: Christopher Croy --- tests/conftest.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index dd4801144fe..0963f2ae611 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -793,8 +793,8 @@ def ptfhosts(enhance_inventory, ansible_adhoc, tbinfo, duthost, request): # when no ptf defined in testbed.csv # try to parse it from inventory ptf_host = duthost.host.options["inventory_manager"].get_host(duthost.hostname).get_vars()["ptf_host"] - _hosts.apend(PTFHost(ansible_adhoc, ptf_host, duthost, tbinfo, - macsec_enabled=request.config.option.enable_macsec)) + _hosts.append(PTFHost(ansible_adhoc, ptf_host, duthost, tbinfo, + macsec_enabled=request.config.option.enable_macsec)) return _hosts From 4b4f386e892f03511ae22a2b9f555ba169d957de Mon Sep 17 00:00:00 2001 From: Mark Xiao Date: Sun, 8 Feb 2026 07:30:29 -0800 Subject: [PATCH 09/43] Ignore "zmq send failed" in test_bgp_suppress_fib.py loganalyzer (#22201) orch_northbond_route_zmq_enabled was enabled in: https://github.com/sonic-net/sonic-mgmt/pull/20441 which enables fpmsyncd to send route events to orchagent via a ZMQ channel instead of Redis. test_bgp_route_with_suppress_negative_operation kills orchagent and restarts bgp session. After restart, fpmsyncd tries to send all updates to orchagent via ZMQ, but orchagent is still down. Eventually, ZMQ buffer is filled up and causes the error. Ignore this error log as it is expected. Signed-off-by: markxiao --- tests/bgp/test_bgp_suppress_fib.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/bgp/test_bgp_suppress_fib.py b/tests/bgp/test_bgp_suppress_fib.py index f967a51fae4..6ea91074ae3 100644 --- a/tests/bgp/test_bgp_suppress_fib.py +++ b/tests/bgp/test_bgp_suppress_fib.py @@ -135,7 +135,8 @@ def ignore_expected_loganalyzer_errors(duthosts, rand_one_dut_hostname, loganaly "\\(.* minutes\\).*", r".* ERR memory_checker: \[memory_checker\] Failed to get container ID of.*", r".* ERR memory_checker: \[memory_checker\] cgroup memory usage file.*", - r".*ERR teamd#teamsyncd: :- readData: netlink reports an error=.*" + r".*ERR teamd#teamsyncd: :- readData: netlink reports an error=.*", + r".*ERR bgp#fpmsyncd: .*zmq send failed.*zmqerrno: 11:Resource temporarily unavailable.*" ] loganalyzer[duthost.hostname].ignore_regex.extend(ignoreRegex) @@ -984,7 +985,8 @@ def test_bgp_route_without_suppress(duthost, tbinfo, nbrhosts, ptfadapter, prepa def test_bgp_route_with_suppress_negative_operation(duthost, tbinfo, nbrhosts, ptfadapter, localhost, prepare_param, - restore_bgp_suppress_fib, generate_route_and_traffic_data): + restore_bgp_suppress_fib, generate_route_and_traffic_data, + loganalyzer): is_v6_topo = is_ipv6_only_topology(tbinfo) try: with allure.step("Prepare needed parameters"): From 3e8ef3e7108f8d0f96def686b1300b62f06e7c55 Mon Sep 17 00:00:00 2001 From: "Austin (Thang Pham)" Date: Mon, 9 Feb 2026 07:49:40 +0700 Subject: [PATCH 10/43] chore: enable test_nhop_group on lt2/ft2 (#22293) Signed-off-by: Austin Pham --- tests/ipfwd/test_nhop_group.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ipfwd/test_nhop_group.py b/tests/ipfwd/test_nhop_group.py index 34177bbb32f..b3ef163b6ab 100644 --- a/tests/ipfwd/test_nhop_group.py +++ b/tests/ipfwd/test_nhop_group.py @@ -23,7 +23,7 @@ PTF_QUEUE_LEN = 100000 pytestmark = [ - pytest.mark.topology('t1', 't2', 'm1') + pytest.mark.topology('t1', 't2', 'm1', "lt2", "ft2") ] logger = logging.getLogger(__name__) From d862683f76a2f90a4f7fabeed2da9d96f6e3a7f4 Mon Sep 17 00:00:00 2001 From: gshemesh2 Date: Mon, 9 Feb 2026 16:35:46 +0200 Subject: [PATCH 11/43] Adding a step to test_interfaces.py to verify pmon contains all interfaces data for the test --- tests/common/platform/processes_utils.py | 20 +++++++++++++++++++ .../mellanox/test_check_sfp_eeprom.py | 2 +- tests/platform_tests/test_xcvr_info_in_db.py | 2 +- tests/test_interfaces.py | 6 ++++++ 4 files changed, 28 insertions(+), 2 deletions(-) diff --git a/tests/common/platform/processes_utils.py b/tests/common/platform/processes_utils.py index 38f5e0c0073..0dd638984c9 100644 --- a/tests/common/platform/processes_utils.py +++ b/tests/common/platform/processes_utils.py @@ -5,6 +5,7 @@ """ import logging import time +import re from tests.common.helpers.assertions import pytest_assert from tests.common.utilities import wait_until, get_plt_reboot_ctrl @@ -12,6 +13,25 @@ logger = logging.getLogger(__name__) +def check_pmon_uptime_minutes(duthost, minimal_runtime=6): + """ + @summary: This function checks if pmon uptime is at least the minimal_runtime + @return: True pmon has been running at least the minimal_runtime, False for otherwise + """ + result = duthost.command("docker ps | grep pmon", _uses_shell=True) + if result["stdout"]: + match = re.search(r'Up (\d+) (minutes|hours)', result["stdout"]) + if match: + if match.group(2) == "hours": + return int(match.group(1))*60 >= minimal_runtime + else: + return int(match.group(1)) >= minimal_runtime + match = re.search(r'Up About an hour', result["stdout"]) + if match: + return 60 >= minimal_runtime + return False + + def reset_timeout(duthost): """ return: if timeout is specified in inventory file for this dut, return new timeout diff --git a/tests/platform_tests/mellanox/test_check_sfp_eeprom.py b/tests/platform_tests/mellanox/test_check_sfp_eeprom.py index 0b5f9e665c2..204876c5fc1 100644 --- a/tests/platform_tests/mellanox/test_check_sfp_eeprom.py +++ b/tests/platform_tests/mellanox/test_check_sfp_eeprom.py @@ -6,7 +6,7 @@ from tests.common.platform.transceiver_utils import parse_sfp_eeprom_infos from tests.common.utilities import wait_until from tests.common.helpers.assertions import pytest_assert -from tests.platform_tests.conftest import check_pmon_uptime_minutes +from tests.common.platform.processes_utils import check_pmon_uptime_minutes pytestmark = [ pytest.mark.asic('mellanox', 'nvidia-bluefield'), diff --git a/tests/platform_tests/test_xcvr_info_in_db.py b/tests/platform_tests/test_xcvr_info_in_db.py index a4ee31408e5..43805a03328 100644 --- a/tests/platform_tests/test_xcvr_info_in_db.py +++ b/tests/platform_tests/test_xcvr_info_in_db.py @@ -11,7 +11,7 @@ from tests.common.fixtures.conn_graph_facts import conn_graph_facts # noqa F401 from tests.common.utilities import wait_until from tests.common.helpers.assertions import pytest_assert -from tests.platform_tests.conftest import check_pmon_uptime_minutes +from tests.common.platform.processes_utils import check_pmon_uptime_minutes pytestmark = [ pytest.mark.topology('any') diff --git a/tests/test_interfaces.py b/tests/test_interfaces.py index 90026cee946..81c0d663f27 100644 --- a/tests/test_interfaces.py +++ b/tests/test_interfaces.py @@ -1,7 +1,9 @@ from netaddr import IPAddress import pytest +from tests.common.utilities import wait_until from tests.common.helpers.assertions import pytest_assert +from tests.common.platform.processes_utils import check_pmon_uptime_minutes pytestmark = [ pytest.mark.topology('any', 't1-multi-asic'), @@ -13,6 +15,10 @@ def test_interfaces(duthosts, enum_frontend_dut_hostname, tbinfo, enum_asic_inde """compare the interfaces between observed states and target state""" duthost = duthosts[enum_frontend_dut_hostname] + + pytest_assert(wait_until(360, 10, 0, check_pmon_uptime_minutes, duthost), + "Pmon docker is not ready for test") + asic_host = duthost.asic_instance(enum_asic_index) host_facts = asic_host.interface_facts()['ansible_facts']['ansible_interface_facts'] mg_facts = asic_host.get_extended_minigraph_facts(tbinfo) From ae012acec7d5e6727dfe618655c7c12bd9760ef1 Mon Sep 17 00:00:00 2001 From: gshemesh2 Date: Mon, 9 Feb 2026 16:39:00 +0200 Subject: [PATCH 12/43] Adjust tests/bgp/test_bgp_update_replication.py::test_bgp_update_replication to handle IPV6 only topologies (#21863) --- tests/bgp/test_bgp_update_replication.py | 41 ++++++++++++++++-------- tests/common/helpers/bgp.py | 23 ++++++------- 2 files changed, 40 insertions(+), 24 deletions(-) diff --git a/tests/bgp/test_bgp_update_replication.py b/tests/bgp/test_bgp_update_replication.py index c4c53a18502..a06adb76f66 100644 --- a/tests/bgp/test_bgp_update_replication.py +++ b/tests/bgp/test_bgp_update_replication.py @@ -12,7 +12,7 @@ from tests.bgp.bgp_helpers import is_neighbor_sessions_established from tests.common.helpers.assertions import pytest_assert -from tests.common.utilities import wait_until +from tests.common.utilities import wait_until, is_ipv6_only_topology logger = logging.getLogger(__name__) @@ -31,12 +31,15 @@ ''' -def generate_routes(num_routes, nexthop): +def generate_routes(num_routes, nexthop, is_ipv6=False): ''' Generator which yields specified amount of dummy routes, in a dict that the route injector can use to announce and withdraw these routes. ''' - SUBNET_TMPL = "10.{first_iter}.{second_iter}.0/24" + if is_ipv6: + SUBNET_TMPL = "2001:db8:{first_iter:x}:{second_iter:x}::/64" + else: + SUBNET_TMPL = "10.{first_iter}.{second_iter}.0/24" loop_iterations = math.floor(num_routes ** 0.5) for first_iter in range(1, loop_iterations + 1): @@ -47,7 +50,7 @@ def generate_routes(num_routes, nexthop): } -def measure_stats(dut): +def measure_stats(dut, is_ipv6=False): ''' Validates that the provided DUT is responsive during test, and that device stats do not exceed specified thresholds, and if so, returns a dictionary containing device statistics @@ -64,7 +67,8 @@ def measure_stats(dut): proc_cpu = dut.shell("show processes cpu | head -n 10", module_ignore_errors=True)['stdout'] time_first_cmd = time.process_time() - bgp_sum = dut.shell("show ip bgp summary | grep memory", module_ignore_errors=True)['stdout'] + bgp_cmd = f"show ip{'v6' if is_ipv6 else ''} bgp summary | grep memory" + bgp_sum = dut.shell(bgp_cmd, module_ignore_errors=True)['stdout'] time_second_cmd = time.process_time() num_cores = dut.shell('cat /proc/cpuinfo | grep "cpu cores" | uniq', module_ignore_errors=True)['stdout'] @@ -187,6 +191,7 @@ def setup_bgp_peers( dut_asn=dut_asn, port=peer_port, neigh_type=neigh_type, + is_ipv6_only=is_ipv6_only_topology(tbinfo), namespace=connection_namespace, is_multihop=is_quagga or is_dualtor, is_passive=False @@ -212,12 +217,14 @@ def setup_bgp_peers( def test_bgp_update_replication( duthost, + tbinfo, setup_bgp_peers, setup_duthost_intervals, ): NUM_ROUTES = 10_000 bgp_peers: list[BGPNeighbor] = setup_bgp_peers duthost_intervals: list[float] = setup_duthost_intervals + is_ipv6 = is_ipv6_only_topology(tbinfo) # Ensure new sessions are ready if not wait_until( @@ -234,7 +241,7 @@ def test_bgp_update_replication( logger.info(f"Route injector: '{route_injector}', route receivers: '{route_receivers}'") - results = [measure_stats(duthost)] + results = [measure_stats(duthost, is_ipv6)] base_rib = int(results[0]["num_rib"]) min_expected_rib = base_rib + NUM_ROUTES max_expected_rib = base_rib + (2 * NUM_ROUTES) @@ -244,12 +251,16 @@ def test_bgp_update_replication( # Repeat 20 times for _ in range(20): # Inject 10000 routes - route_injector.announce_routes_batch(generate_routes(num_routes=NUM_ROUTES, nexthop=route_injector.ip)) - + route_injector.announce_routes_batch( + generate_routes( + num_routes=NUM_ROUTES, nexthop=route_injector.ip, + is_ipv6=is_ipv6 + ) + ) time.sleep(interval) # Measure after injection - results.append(measure_stats(duthost)) + results.append(measure_stats(duthost, is_ipv6)) # Validate all routes have been received curr_num_rib = int(results[-1]["num_rib"]) @@ -263,12 +274,16 @@ def test_bgp_update_replication( ) # Remove routes - route_injector.withdraw_routes_batch(generate_routes(num_routes=NUM_ROUTES, nexthop=route_injector.ip)) - + route_injector.withdraw_routes_batch( + generate_routes( + num_routes=NUM_ROUTES, nexthop=route_injector.ip, + is_ipv6=is_ipv6 + ) + ) time.sleep(interval) # Measure after removal - results.append(measure_stats(duthost)) + results.append(measure_stats(duthost, is_ipv6)) # Validate all routes have been withdrawn curr_num_rib = int(results[-1]["num_rib"]) @@ -281,7 +296,7 @@ def test_bgp_update_replication( f"All announcements have not been withdrawn: current '{curr_num_rib}', expected: '{base_rib}'" ) - results.append(measure_stats(duthost)) + results.append(measure_stats(duthost, is_ipv6)) # Output results as TSV for analysis in other programs results_tsv = tabulate(results, headers="keys", tablefmt="tsv") diff --git a/tests/common/helpers/bgp.py b/tests/common/helpers/bgp.py index e1b7ee1962a..49a4eedab89 100644 --- a/tests/common/helpers/bgp.py +++ b/tests/common/helpers/bgp.py @@ -130,7 +130,8 @@ def __init__(self, duthost, ptfhost, name, neighbor_ip, neighbor_asn, dut_ip, dut_asn, port, neigh_type=None, namespace=None, is_multihop=False, is_passive=False, debug=False, - confed_asn=None, use_vtysh=False): + is_ipv6_only=False, router_id=None, confed_asn=None, use_vtysh=False): + self.duthost = duthost self.ptfhost = ptfhost self.ptfip = ptfhost.mgmt_ip @@ -145,6 +146,14 @@ def __init__(self, duthost, ptfhost, name, self.is_passive = is_passive self.is_multihop = not is_passive and is_multihop self.debug = debug + self.is_ipv6_neighbor = is_ipv6_only + if not self.is_ipv6_neighbor: + self.router_id = router_id or self.ip + else: + # Generate router ID by combining 20.0.0.0 base with last 3 bytes of IPv6 addr + router_id_base = ipaddress.IPv4Address("20.0.0.0") + ipv6_addr = ipaddress.IPv6Address(self.ip) + self.router_id = router_id or str(ipaddress.IPv4Address(int(router_id_base) | int(ipv6_addr) & 0xFFFFFF)) self.use_vtysh = use_vtysh self.confed_asn = confed_asn @@ -185,19 +194,11 @@ def start_session(self): peer_name=self.name ) - if ipaddress.ip_address(self.ip).version == 4: - router_id = self.ip - else: - # Generate router ID by combining 20.0.0.0 base with last 3 bytes of IPv6 addr - router_id_base = ipaddress.IPv4Address("20.0.0.0") - ipv6_addr = ipaddress.IPv6Address(self.ip) - router_id = str(ipaddress.IPv4Address(int(router_id_base) | int(ipv6_addr) & 0xFFFFFF)) - self.ptfhost.exabgp( name=self.name, - state="started", + state="restarted" if self.is_ipv6_neighbor else "started", local_ip=self.ip, - router_id=router_id, + router_id=self.router_id, peer_ip=self.peer_ip, local_asn=self.asn, peer_asn=self.confed_asn if self.confed_asn is not None else self.peer_asn, From 869f9dbc4217f05591289e84cce4c395ec493ca5 Mon Sep 17 00:00:00 2001 From: OriTrabelsi Date: Mon, 9 Feb 2026 16:46:10 +0200 Subject: [PATCH 13/43] Adjust bgp commands for v6 when v6 only. (#22167) --- tests/bgp/conftest.py | 11 +++++++++++ tests/bgp/constants.py | 1 + tests/bgp/test_bgp_session.py | 1 + tests/bgp/test_bgp_stress_link_flap.py | 4 ++-- 4 files changed, 15 insertions(+), 2 deletions(-) diff --git a/tests/bgp/conftest.py b/tests/bgp/conftest.py index 92e4d86bbe9..2a3ad6512af 100644 --- a/tests/bgp/conftest.py +++ b/tests/bgp/conftest.py @@ -11,6 +11,7 @@ import socket from jinja2 import Template +from tests.bgp.constants import SHOW_IP_INTERFACE_CMD from tests.common.helpers.assertions import pytest_assert as pt_assert from tests.common.helpers.generators import generate_ips from tests.common.helpers.parallel import parallel_run @@ -827,3 +828,13 @@ def traffic_shift_community(duthost): @pytest.fixture(scope='module') def get_function_completeness_level(pytestconfig): return pytestconfig.getoption("--completeness_level") + + +@pytest.fixture(scope='module') +def ip_version(tbinfo): + return 'v6' if is_ipv6_only_topology(tbinfo) else 'v4' + + +@pytest.fixture(scope='module') +def show_ip_interface_cmd(ip_version): + return SHOW_IP_INTERFACE_CMD[ip_version] diff --git a/tests/bgp/constants.py b/tests/bgp/constants.py index 57a06f5f09d..a23322ef910 100644 --- a/tests/bgp/constants.py +++ b/tests/bgp/constants.py @@ -4,3 +4,4 @@ TS_INCONSISTENT = "System Mode: Not consistent" TS_NO_NEIGHBORS = "System Mode: No external neighbors" TS_UNEXPECTED = "TSC not consistent across asic namespaces" +SHOW_IP_INTERFACE_CMD = {'v4': "show ip interface", 'v6': "show ipv6 interface"} diff --git a/tests/bgp/test_bgp_session.py b/tests/bgp/test_bgp_session.py index f648d99564b..cee9465b3e1 100644 --- a/tests/bgp/test_bgp_session.py +++ b/tests/bgp/test_bgp_session.py @@ -222,6 +222,7 @@ def test_bgp_session_interface_down(duthosts, rand_one_dut_hostname, fanouthosts time.sleep(1) duthost.shell('show ip bgp summary', module_ignore_errors=True) + duthost.shell('show ipv6 bgp summary', module_ignore_errors=True) try: # default keepalive is 60 seconds, timeout 180 seconds. Hence wait for 180 seconds before timeout. diff --git a/tests/bgp/test_bgp_stress_link_flap.py b/tests/bgp/test_bgp_stress_link_flap.py index e1781b386bd..3cc73c58bae 100644 --- a/tests/bgp/test_bgp_stress_link_flap.py +++ b/tests/bgp/test_bgp_stress_link_flap.py @@ -30,7 +30,7 @@ @pytest.fixture(scope='module') -def setup(duthosts, rand_one_dut_hostname, nbrhosts, fanouthosts): +def setup(duthosts, rand_one_dut_hostname, nbrhosts, fanouthosts, show_ip_interface_cmd): duthost = duthosts[rand_one_dut_hostname] config_facts = duthost.config_facts(host=duthost.hostname, source="running")['ansible_facts'] @@ -53,7 +53,7 @@ def setup(duthosts, rand_one_dut_hostname, nbrhosts, fanouthosts): pytest_assert(wait_until(30, 5, 0, duthost.check_bgp_session_state, list(bgp_neighbors.keys())), "Not all BGP sessions are established on DUT") - ip_intfs = duthost.show_and_parse('show ip interface') + ip_intfs = duthost.show_and_parse(show_ip_interface_cmd) logger.debug("setup ip_intfs {}".format(ip_intfs)) # Create a mapping of neighbor IP to interfaces and their details From 4020092dd5e325858158aab4525835542e8824c9 Mon Sep 17 00:00:00 2001 From: Yael Tzur Date: Mon, 9 Feb 2026 16:50:34 +0200 Subject: [PATCH 14/43] adjust buffer test to support SN6600 --- tests/qos/files/dynamic_buffer_param.json | 24 ++++++++++++++----- tests/qos/test_buffer.py | 29 ++++++++++++++++++----- 2 files changed, 41 insertions(+), 12 deletions(-) diff --git a/tests/qos/files/dynamic_buffer_param.json b/tests/qos/files/dynamic_buffer_param.json index c57667d79d1..e7eb17fd25d 100644 --- a/tests/qos/files/dynamic_buffer_param.json +++ b/tests/qos/files/dynamic_buffer_param.json @@ -27,7 +27,8 @@ }, "extra_overhead": { "8": "95232", - "default": "58368" + "default": "58368", + "x86_64-nvidia_sn6600_simx-r0": "96256" }, "shared-headroom-pool": { "size": "1024000", @@ -38,10 +39,16 @@ "0": "[BUFFER_PROFILE_TABLE:ingress_lossy_pg_zero_profile]" }, "BUFFER_QUEUE_TABLE": { - "0-2": "[BUFFER_PROFILE_TABLE:egress_lossy_zero_profile]", - "3-4": "[BUFFER_PROFILE_TABLE:egress_lossless_zero_profile]", - "5-6": "[BUFFER_PROFILE_TABLE:egress_lossy_zero_profile]", - "7-15": "[BUFFER_PROFILE_TABLE:egress_lossy_zero_profile]" + "default": + {"0-2": "[BUFFER_PROFILE_TABLE:egress_lossy_zero_profile]", + "3-4": "[BUFFER_PROFILE_TABLE:egress_lossless_zero_profile]", + "5-6": "[BUFFER_PROFILE_TABLE:egress_lossy_zero_profile]", + "7-15": "[BUFFER_PROFILE_TABLE:egress_lossy_zero_profile]"}, + "x86_64-nvidia_sn6600_simx-r0": + {"0-2": "[BUFFER_PROFILE_TABLE:egress_lossy_zero_profile]", + "3-4": "[BUFFER_PROFILE_TABLE:egress_lossless_zero_profile]", + "5-6": "[BUFFER_PROFILE_TABLE:egress_lossy_zero_profile]", + "7-11": "[BUFFER_PROFILE_TABLE:egress_lossy_zero_profile]"} }, "BUFFER_PORT_INGRESS_PROFILE_LIST_TABLE": ["[BUFFER_PROFILE_TABLE:ingress_lossless_zero_profile]"], "BUFFER_PORT_EGRESS_PROFILE_LIST_TABLE": ["[BUFFER_PROFILE_TABLE:egress_lossless_zero_profile]", "[BUFFER_PROFILE_TABLE:egress_lossy_zero_profile]"] @@ -56,7 +63,12 @@ "x86_64-nvidia_sn4800_simx-r0": "400000", "x86_64-nvidia_sn5600-r0": "800000", "x86_64-nvidia_sn5640-r0": "800000", - "x86_64-nvidia_sn5600_simx-r0": "800000" + "x86_64-nvidia_sn5600_simx-r0": "800000", + "x86_64-nvidia_sn6600_simx-r0": "800000" + }, + "supported_speeds_to_test": { + "default": ["10000", "50000"], + "x86_64-nvidia_sn6600_simx-r0": ["100000"] } } } diff --git a/tests/qos/test_buffer.py b/tests/qos/test_buffer.py index c195421090e..e3348b60553 100644 --- a/tests/qos/test_buffer.py +++ b/tests/qos/test_buffer.py @@ -39,6 +39,7 @@ PORTS_WITH_8LANES = None ASIC_TYPE = None +PLATFORM_SUPPORTED_SPEEDS_TO_TEST = None TESTPARAM_HEADROOM_OVERRIDE = None TESTPARAM_LOSSLESS_PG = None TESTPARAM_SHARED_HEADROOM_POOL = None @@ -227,6 +228,7 @@ def load_test_parameters(duthost): global TESTPARAM_ADMIN_DOWN global ASIC_TYPE global MAX_SPEED_8LANE_PORT + global PLATFORM_SUPPORTED_SPEEDS_TO_TEST param_file_name = "qos/files/dynamic_buffer_param.json" with open(param_file_name) as file: @@ -234,6 +236,7 @@ def load_test_parameters(duthost): logging.info("Loaded test parameters {} from {}".format( params, param_file_name)) ASIC_TYPE = duthost.facts['asic_type'] + platform = duthost.facts['platform'] vendor_specific_param = params[ASIC_TYPE] DEFAULT_CABLE_LENGTH_LIST = vendor_specific_param['default_cable_length'] TESTPARAM_HEADROOM_OVERRIDE = vendor_specific_param['headroom-override'] @@ -241,8 +244,17 @@ def load_test_parameters(duthost): TESTPARAM_SHARED_HEADROOM_POOL = vendor_specific_param['shared-headroom-pool'] TESTPARAM_EXTRA_OVERHEAD = vendor_specific_param['extra_overhead'] TESTPARAM_ADMIN_DOWN = vendor_specific_param['admin-down'] - MAX_SPEED_8LANE_PORT = vendor_specific_param['max_speed_8lane_platform'].get( - duthost.facts['platform']) + MAX_SPEED_8LANE_PORT = vendor_specific_param['max_speed_8lane_platform'].get(platform) + if buffer_queue_table := TESTPARAM_ADMIN_DOWN['BUFFER_QUEUE_TABLE'].get(platform): # noqa: F841 + TESTPARAM_ADMIN_DOWN['BUFFER_QUEUE_TABLE'] = buffer_queue_table + else: + TESTPARAM_ADMIN_DOWN['BUFFER_QUEUE_TABLE'] = TESTPARAM_ADMIN_DOWN['BUFFER_QUEUE_TABLE'].get('default') + if platform_extra_overhead := TESTPARAM_EXTRA_OVERHEAD.get(platform): + TESTPARAM_EXTRA_OVERHEAD['default'] = platform_extra_overhead + if platform_supported_speeds_to_test := vendor_specific_param['supported_speeds_to_test'].get(platform): + PLATFORM_SUPPORTED_SPEEDS_TO_TEST = platform_supported_speeds_to_test + else: + PLATFORM_SUPPORTED_SPEEDS_TO_TEST = vendor_specific_param['supported_speeds_to_test'].get('default') # For ingress profile list, we need to check whether the ingress lossy profile exists ingress_lossy_pool = duthost.shell( @@ -850,7 +862,7 @@ def make_expected_profile_name(speed, cable_length, **kwargs): return expected_profile -@pytest.fixture(params=['50000', '10000']) +@pytest.fixture(params=['50000', '10000', '100000']) def speed_to_test(request): """Used to parametrized test cases for speeds @@ -860,6 +872,11 @@ def speed_to_test(request): Return: speed_to_test """ + global PLATFORM_SUPPORTED_SPEEDS_TO_TEST + if not PLATFORM_SUPPORTED_SPEEDS_TO_TEST: + pytest.skip("buffer is not dynamic - PLATFORM_SUPPORTED_SPEEDS_TO_TEST wasn't set") + if request.param not in PLATFORM_SUPPORTED_SPEEDS_TO_TEST: + pytest.skip(f"Skipping case for speed {request.param} because it is not tested by the platform") return request.param @@ -998,7 +1015,7 @@ def test_change_speed_cable(duthosts, rand_one_dut_hostname, conn_graph_facts, """ duthost = duthosts[rand_one_dut_hostname] supported_speeds = duthost.shell( - 'redis-cli -n 6 hget "PORT_TABLE|{}" supported_speeds'.format(port_to_test))['stdout'] + 'redis-cli -n 6 hget "PORT_TABLE|{}" supported_speeds'.format(port_to_test))['stdout'].split(',') if supported_speeds and speed_to_test not in supported_speeds: pytest.skip('Speed is not supported by the port, skip') original_speed = duthost.shell( @@ -2598,10 +2615,10 @@ def _update_headroom_exceed_Larger_size(param_name): # This should make it exceed the limit, so the profile should not applied to the APPL_DB time.sleep(20) size_in_appldb = duthost.shell( - f'redis-cli hget "BUFFER_PROFILE_TABLE:test-headroom" {param_name}')['stdout'] + f'redis-cli hget "BUFFER_PROFILE_TABLE: test-headroom" {param_name}')['stdout'] pytest_assert(size_in_appldb == maximum_profile[param_name], f'The profile with a large size was applied to APPL_DB, which can make headroom exceeding. ' - f'size_in_appldb:{size_in_appldb}, ' + f'size_in_appldb: {size_in_appldb}, ' f'maximum_profile_{param_name}: {maximum_profile[param_name]}') param_name = "size" if disable_shp else "xoff" From 16823cd7b68c87e03322d53868c933e0a9ad8d0a Mon Sep 17 00:00:00 2001 From: Saikrishna Arcot Date: Mon, 9 Feb 2026 09:47:43 -0800 Subject: [PATCH 15/43] Add GCU test case to test changing the source interface for NTP (#21561) What is the motivation for this PR? Expand the current NTP test case to verify that changing the source interface configuration for NTP works correctly. How did you do it? Add a test case to change the NTP source interface to Loopback0, and then back to eth0. How did you verify/test it? Tested on T0 KVM. Signed-off-by: Saikrishna Arcot --- tests/generic_config_updater/test_ntp.py | 50 ++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/tests/generic_config_updater/test_ntp.py b/tests/generic_config_updater/test_ntp.py index 83baad34361..103cc0e0232 100644 --- a/tests/generic_config_updater/test_ntp.py +++ b/tests/generic_config_updater/test_ntp.py @@ -297,3 +297,53 @@ def test_ntp_server_tc1_suite(rand_selected_dut): ntp_server_tc1_xfail(rand_selected_dut) ntp_server_tc1_replace(rand_selected_dut, ntp_service) ntp_server_tc1_remove(rand_selected_dut, ntp_service) + + +def ntp_server_set_intf(duthost, ntp_service, src_intf): + """ Test to set NTP source interface + """ + json_patch = [ + { + "op": "add", + "path": "/NTP", + "value": { + "global": { + "src_intf": src_intf + } + } + } + ] + json_patch = format_json_patch_for_multiasic(duthost=duthost, json_data=json_patch, is_host_specific=True) + + tmpfile = generate_tmpfile(duthost) + logger.info("tmpfile {}".format(tmpfile)) + + ntp_daemon = get_ntp_daemon_in_use(duthost) + + try: + start_time = int(duthost.command("date +%s")['stdout'].strip()) + output = apply_patch(duthost, json_data=json_patch, dest_file=tmpfile) + expect_op_success(duthost, output) + + pytest_assert( + ntp_service_restarted(duthost, ntp_service, start_time), + f"{ntp_service} is not restarted after change" + ) + + if ntp_daemon == NtpDaemon.CHRONY: + pytest_assert( + server_exist_in_conf(duthost, f"bindacqdevice {src_intf}"), + f"Failed to set source interface to {src_intf}" + ) + + finally: + delete_tmpfile(duthost, tmpfile) + + +def test_ntp_server_change_source_intf(rand_selected_dut): + """ Test changing the source interface via GCU + """ + ntp_service = get_ntp_service_name(rand_selected_dut) + + ntp_server_set_intf(rand_selected_dut, ntp_service, "Loopback0") + ntp_server_set_intf(rand_selected_dut, ntp_service, "eth0") From 9b47ceb3ceba58e611e234d107b3c9d46a52f76e Mon Sep 17 00:00:00 2001 From: Saikrishna Arcot Date: Mon, 9 Feb 2026 09:48:58 -0800 Subject: [PATCH 16/43] Add GCU test case for adding/removing DNS nameservers (#21588) What is the motivation for this PR? Add a test case to verify the ability to add and remove DNS nameservers via incremental config. How did you do it? Model this after the NTP GCU test case, and add two random DNS nameservers and remove one. How did you verify/test it? Tested locally on KVM T0. Signed-off-by: Saikrishna Arcot --- .../test_dns_nameserver.py | 156 ++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 tests/generic_config_updater/test_dns_nameserver.py diff --git a/tests/generic_config_updater/test_dns_nameserver.py b/tests/generic_config_updater/test_dns_nameserver.py new file mode 100644 index 00000000000..8cd594e41fa --- /dev/null +++ b/tests/generic_config_updater/test_dns_nameserver.py @@ -0,0 +1,156 @@ +import logging +import pytest +import re + +from tests.common.helpers.assertions import pytest_assert +from tests.common.gu_utils import apply_patch, expect_op_success +from tests.common.gu_utils import generate_tmpfile, delete_tmpfile +from tests.common.gu_utils import format_json_patch_for_multiasic +from tests.common.gu_utils import create_checkpoint, delete_checkpoint, rollback_or_reload + +logger = logging.getLogger(__name__) + +pytestmark = [ + pytest.mark.topology('any'), + pytest.mark.device_type('vs') +] + +DNS_SERVER_RE = "nameserver {}" + + +@pytest.fixture(autouse=True) +def setup_env(duthosts, rand_one_dut_hostname): # noqa: F811 + """ + Setup/teardown fixture for dns nameserver config + Args: + duthosts: list of DUTs. + rand_selected_dut: The fixture returns a randomly selected DuT. + """ + duthost = duthosts[rand_one_dut_hostname] + create_checkpoint(duthost) + + init_dns_nameservers = current_dns_nameservers(duthost) + + yield + + try: + logger.info("Rolled back to original checkpoint") + rollback_or_reload(duthost) + + cur_dns_nameservers = current_dns_nameservers(duthost) + pytest_assert(cur_dns_nameservers == init_dns_nameservers, + "DNS nameservers {} do not match {}.".format( + cur_dns_nameservers, init_dns_nameservers)) + finally: + delete_checkpoint(duthost) + + +def current_dns_nameservers(duthost): + """ Ger running dns nameservers + """ + config_facts = duthost.config_facts(host=duthost.hostname, + source="running")['ansible_facts'] + dns_nameservers = config_facts.get('DNS_NAMESERVER', {}) + return dns_nameservers + + +def dns_nameserver_test_setup(duthost): + """ Clean up dns nameservers before test + """ + dns_nameservers = current_dns_nameservers(duthost) + for dns_nameserver in dns_nameservers: + duthost.command("config dns nameserver del %s" % dns_nameserver) + + +def server_exist_in_conf(duthost, server_pattern): + """ Check if dns nameserver take effect in resolv.conf + """ + content = duthost.command("cat /etc/resolv.conf") + for line in content['stdout_lines']: + if re.search(server_pattern, line): + return True + return False + + +def add_dns_nameserver(duthost, dns_nameserver): + json_patch = [ + { + "op": "add", + "path": f"/DNS_NAMESERVER/{dns_nameserver}", + "value": {} + } + ] + json_patch = format_json_patch_for_multiasic(duthost=duthost, + json_data=json_patch, + is_host_specific=True) + + json_patch_bc = [ + { + "op": "remove", + "path": f"/DNS_NAMESERVER/{dns_nameserver}", + "value": {} + } + ] + json_patch_bc = format_json_patch_for_multiasic(duthost=duthost, + json_data=json_patch_bc, + is_host_specific=True) + + tmpfile = generate_tmpfile(duthost) + logger.info("tmpfile {}".format(tmpfile)) + + try: + output = apply_patch(duthost, json_data=json_patch, dest_file=tmpfile) + if output['rc'] != 0: + logger.error(f"Failed to apply patch, rolling back: {output['stdout']}") + apply_patch(duthost, json_data=json_patch_bc, dest_file=tmpfile) + expect_op_success(duthost, output) + + pytest_assert( + server_exist_in_conf(duthost, DNS_SERVER_RE.format(dns_nameserver)), + "Failed to add {} in /etc/resolv.conf".format(dns_nameserver) + ) + + finally: + delete_tmpfile(duthost, tmpfile) + + +def remove_dns_nameserver(duthost, dns_nameserver): + json_patch = [ + { + "op": "remove", + "path": f"/DNS_NAMESERVER/{dns_nameserver}", + "value": {} + } + ] + json_patch = format_json_patch_for_multiasic(duthost=duthost, + json_data=json_patch, + is_host_specific=True) + + tmpfile = generate_tmpfile(duthost) + logger.info("tmpfile {}".format(tmpfile)) + + try: + output = apply_patch(duthost, json_data=json_patch, dest_file=tmpfile) + expect_op_success(duthost, output) + + pytest_assert( + not server_exist_in_conf(duthost, DNS_SERVER_RE.format(dns_nameserver)), + "Failed to remove {} from /etc/resolv.conf".format(dns_nameserver) + ) + + finally: + delete_tmpfile(duthost, tmpfile) + + +def test_dns_server_add_and_remove(rand_selected_dut): + """ Test suite for dns nameserver + """ + + dns_nameserver_test_setup(rand_selected_dut) + add_dns_nameserver(rand_selected_dut, "10.6.3.1") + add_dns_nameserver(rand_selected_dut, "10.6.3.2") + remove_dns_nameserver(rand_selected_dut, "10.6.3.1") + + # Removing the last DNS server isn't supported under GCU, so let config + # rollback take care of it + # remove_dns_nameserver(rand_selected_dut, "10.6.3.2") From 0f0a911fe25ac496a090d1bbf5e35b0c2b7729cb Mon Sep 17 00:00:00 2001 From: lakshmi-nexthop Date: Mon, 9 Feb 2026 11:55:25 -0800 Subject: [PATCH 17/43] [Disagg T2] sonic-mgmt test changes needed for single ASIC VOQ Fixed-System (#20872) What is the motivation for this PR? sonic-mgmt tests need to be improved for FS voq How did you do it? access to Chassis DB is done only if the voq is a chassis skipped test_po_voq.py for single ASIC FS as this test checks for additons/deletions/updates of LAGs across all asics in a chassis using chassis APP DB How did you verify/test it? ran the tests on FS voq Signed-off-by: Lakshmi Yarramaneni Co-authored-by: Xin Wang --- ansible/group_vars/sonic/variables | 2 +- ansible/library/dut_basic_facts.py | 4 ++ tests/common/fixtures/fib_utils.py | 5 +- .../tests_mark_conditions_voq.yaml | 65 +++++++++++++++++++ tests/lldp/test_lldp_syncd.py | 3 +- 5 files changed, 76 insertions(+), 3 deletions(-) create mode 100644 tests/common/plugins/conditional_mark/tests_mark_conditions_voq.yaml diff --git a/ansible/group_vars/sonic/variables b/ansible/group_vars/sonic/variables index 257b7c50997..03d72accbbf 100644 --- a/ansible/group_vars/sonic/variables +++ b/ansible/group_vars/sonic/variables @@ -21,7 +21,7 @@ broadcom_th5_hwskus: ['Arista-7060X6-64DE', 'Arista-7060X6-64DE-64x400G', 'Arist broadcom_j2c+_hwskus: ['Nokia-IXR7250E-36x100G', 'Nokia-IXR7250E-36x400G', 'Arista-7280DR3A-36', 'Arista-7280DR3AK-36', 'Arista-7280DR3AK-36S', 'Arista-7280DR3AM-36', 'Arista-7800R3A-36DM2-C36', 'Arista-7800R3A-36DM2-D36', 'Arista-7800R3AK-36DM2-C36', 'Arista-7800R3AK-36DM2-D36', 'Nokia-IXR7250-X3B'] broadcom_jr2_hwskus: ['Arista-7800R3-48CQ2-C48', 'Arista-7800R3-48CQM2-C48'] -broadcom_q3d_hwskus: ['Arista-7280R4-32QF-32DF-64O', 'Arista-7280R4K-32QF-32DF-64O'] +broadcom_q3d_hwskus: ['NH-5010-F-O64', 'NH-5010-F-O32-C32', 'Arista-7280R4-32QF-32DF-64O', 'Arista-7280R4K-32QF-32DF-64O'] mellanox_spc1_hwskus: [ 'ACS-MSN2700', 'ACS-MSN2740', 'ACS-MSN2100', 'ACS-MSN2410', 'ACS-MSN2010', 'Mellanox-SN2700', 'Mellanox-SN2700-A1', 'Mellanox-SN2700-D48C8','Mellanox-SN2700-D40C8S8', 'Mellanox-SN2700-A1-D48C8', 'Mellanox-SN2700-C28D8', 'Mellanox-SN2700-A1-C28D8'] mellanox_spc2_hwskus: [ 'ACS-MSN3700', 'ACS-MSN3700C', 'ACS-MSN3800', 'Mellanox-SN3800-D112C8' , 'ACS-MSN3420'] diff --git a/ansible/library/dut_basic_facts.py b/ansible/library/dut_basic_facts.py index c5acb015162..d9654b0d9c3 100644 --- a/ansible/library/dut_basic_facts.py +++ b/ansible/library/dut_basic_facts.py @@ -51,6 +51,10 @@ def main(): if hasattr(device_info, 'is_chassis'): results['is_chassis'] = device_info.is_chassis() + results['is_chassis_config_absent'] = False + if hasattr(device_info, 'is_chassis_config_absent'): + results['is_chassis_config_absent'] = device_info.is_chassis_config_absent() + if results['is_multi_asic']: results['asic_index_list'] = [] if results['is_chassis']: diff --git a/tests/common/fixtures/fib_utils.py b/tests/common/fixtures/fib_utils.py index 3a46cd580f6..a43720dae22 100644 --- a/tests/common/fixtures/fib_utils.py +++ b/tests/common/fixtures/fib_utils.py @@ -44,16 +44,19 @@ def get_t2_fib_info(duthosts, duts_cfg_facts, duts_mg_facts, testname=None): dut_inband_intfs = {} dut_port_channels = {} switch_type = '' + is_chassis = False for duthost in duthosts.frontend_nodes: cfg_facts = duts_cfg_facts[duthost.hostname] for asic_cfg_facts in cfg_facts: if duthost.facts['switch_type'] == "voq": + is_chassis = \ + not duthost.dut_basic_facts()['ansible_facts']['dut_basic_facts'].get("is_chassis_config_absent") switch_type = "voq" if 'VOQ_INBAND_INTERFACE' in asic_cfg_facts[1]: dut_inband_intfs.setdefault(duthost.hostname, []).extend(asic_cfg_facts[1]['VOQ_INBAND_INTERFACE']) dut_port_channels.setdefault(duthost.hostname, {}).update(asic_cfg_facts[1].get('PORTCHANNEL_MEMBER', {})) sys_neigh = {} - if switch_type == "voq": + if switch_type == "voq" and is_chassis: if len(duthosts) == 1: voq_db = VoqDbCli(duthosts.frontend_nodes[0]) else: diff --git a/tests/common/plugins/conditional_mark/tests_mark_conditions_voq.yaml b/tests/common/plugins/conditional_mark/tests_mark_conditions_voq.yaml new file mode 100644 index 00000000000..b682a0af4f0 --- /dev/null +++ b/tests/common/plugins/conditional_mark/tests_mark_conditions_voq.yaml @@ -0,0 +1,65 @@ +####################################### +#####test_po_voq.py ##### +####################################### +pc/test_po_voq.py: + skip: + reason: "Skipped as the test applies to chassis only" + conditions: + - "is_chassis_config_absent==True" + +################################################# +#####test_voq_chassis_app_db_consistency.py ##### +################################################# +voq/test_voq_chassis_app_db_consistency.py: + skip: + reason: "Skipped as the test applies to chassis only" + conditions: + - "is_chassis_config_absent==True" + +################################################# +#####test_voq_disrupts.py ##### +################################################# +voq/test_voq_disrupts.py: + skip: + reason: "Skipped as the test applies to chassis only" + conditions: + - "is_chassis_config_absent==True" + +####################################### +#####test_voq_init.py ##### +####################################### +voq/test_voq_init.py::test_voq_inband_port_create: + skip: + reason: "Skipped as the test applies to chassis only" + conditions: + - "is_chassis_config_absent==True" + +voq/test_voq_init.py::test_voq_interface_create: + skip: + reason: "Skipped as the test applies to chassis only" + conditions: + - "is_chassis_config_absent==True" + +voq/test_voq_init.py::test_voq_neighbor_create: + skip: + reason: "Skipped as the test applies to chassis only" + conditions: + - "is_chassis_config_absent==True" + +################################################# +#####test_voq_ipfwd.py ##### +################################################# +voq/test_voq_ipfwd.py: + skip: + reason: "Skipped as the test applies to chassis only" + conditions: + - "is_chassis_config_absent==True" + +################################################# +#####test_voq_nbr.py ##### +################################################# +voq/test_voq_nbr.py: + skip: + reason: "Skipped as the test applies to chassis only" + conditions: + - "is_chassis_config_absent==True" diff --git a/tests/lldp/test_lldp_syncd.py b/tests/lldp/test_lldp_syncd.py index 64209639103..5212308eaf6 100644 --- a/tests/lldp/test_lldp_syncd.py +++ b/tests/lldp/test_lldp_syncd.py @@ -40,7 +40,8 @@ def db_instance(duthosts, enum_rand_one_per_hwsku_frontend_hostname): appl_db.append(SonicDbCli(asic, APPL_DB)) duthost.facts['switch_type'] == "voq" is_chassis = duthost.get_facts().get("modular_chassis") - if duthost.facts['switch_type'] == "voq" and not is_chassis: + # For single ASIC fixed system, APPL_DB is already added above. so skip here + if duthost.facts['switch_type'] == "voq" and (not is_chassis and len(duthost.asics) > 1): appl_db.append(SonicDbCli(duthost, APPL_DB)) # Cleanup code here return appl_db From 75e4192437559c6ef2d8c25c96b14b3194e52d11 Mon Sep 17 00:00:00 2001 From: rajshekhar-nexthop Date: Tue, 10 Feb 2026 01:29:09 +0530 Subject: [PATCH 18/43] Fix MACsec test reliability and configuration issues (#21372) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ISS-2888:Fix JSON syntax in golden_config_db_t2.j2 template (#401) ### Description of PR Summary: Fixes # (issue) Fixes below json syntax error. It is seen only when dut is prepared with macsec enable flag. json.decoder.JSONDecodeError: Expecting property name enclosed in double quotes: line 2 column 3 (char 4) ### Type of change - [x] Bug fix - [ ] Testbed and Framework(new/improvement) - [ ] New Test case - [ ] Skipped for non-supported platforms - [ ] Test case improvement ### Back port request - [ ] 202205 - [ ] 202305 - [ ] 202311 - [ ] 202405 - [ ] 202411 - [ ] 202505 ### Approach #### What is the motivation for this PR? #### How did you do it? #### How did you verify/test it? #### Any platform specific information? #### Supported testbed topology if it's a new test case? ### Documentation Signed-off-by: rajshekhar * ISS-2969:Generate golden config only if macsec_profile is defined (#420) ### Description of PR Redundant override config is avoided as no macsec profile is set in the prepare phase. Below are details how macsec profile configurations are rendered: PREPARE phase: Uses generate_t2_golden_config_db() → template rendering → file-based config RUN phase: Uses set_macsec_profile() → direct sonic-db-cli commands → immediate CONFIG_DB update Summary: Fixes # (issue) ### Type of change - [x] Bug fix - [ ] Testbed and Framework(new/improvement) - [ ] New Test case - [ ] Skipped for non-supported platforms - [ ] Test case improvement ### Back port request - [ ] 202205 - [ ] 202305 - [ ] 202311 - [ ] 202405 - [ ] 202411 - [ ] 202505 ### Approach #### What is the motivation for this PR? #### How did you do it? #### How did you verify/test it? #### Any platform specific information? #### Supported testbed topology if it's a new test case? ### Documentation Signed-off-by: rajshekhar * ISS-3251: Guard MACsec restart against systemd StartLimitHit; add restart helper (#562) ### Description of PR Summary: • Add a StartLimitHit-safe restart helper and use it in MACsec docker restart test to reduce flakiness • New helper restart_service_with_startlimit_guard() in tests/common/helpers/dut_utils.py: • Proactively clears systemd failure counters (systemctl reset-failed) • Attempts restart, detects systemd rate limiting (StartLimitHit), applies bounded backoff (default 35s), then start • Verifies the target container becomes running within a timeout • Update tests/macsec/test_docker_restart.py to use the new helper instead of duthost.restart_service("macsec") Fixes # (issue) MACsec docker restart tests can intermittently fail due to systemd rate limiting after repeated restarts during teardown/restart cycles. • Guarding against StartLimitHit with a clear backoff-and-start flow improves test reliability without changing device behavior. ### Type of change - [ ] Bug fix - [ ] Testbed and Framework(new/improvement) - [ ] New Test case - [ ] Skipped for non-supported platforms - [ x] Test case improvement ### Back port request - [ ] 202205 - [ ] 202305 - [ ] 202311 - [ ] 202405 - [ ] 202411 - [ ] 202505 ### Approach #### What is the motivation for this PR? • MACsec docker restart tests can intermittently fail when systemd enforces StartLimitHit due to rapid restart attempts during teardown/restart cycles. • This PR makes the restart path resilient to StartLimitHit by proactively clearing counters, applying bounded backoff, and verifying the container reaches the running state, thereby reducing test flakiness. #### How did you do it? • Added a helper restart_service_with_startlimit_guard() in tests/common/helpers/dut_utils.py that: • Detects StartLimitHit pre/post restart attempts • Runs systemctl reset-failed to clear counters • Applies a fixed backoff when rate-limited, then systemctl start • Verifies the container is running within a configurable timeout using existing wait_until/state checks • Updated tests/macsec/test_docker_restart.py to use the helper instead of a direct duthost.restart_service("macsec") call. #### How did you verify/test it? • Local validation in lab: • Executed tests/macsec/test_docker_restart.py::test_restart_macsec_docker with MACsec enabled. • Repeated the restart sequence to emulate rate limiting scenarios. • Verified the helper reliably recovers from StartLimitHit and the container becomes running within the timeout. #### Any platform specific information? #### Supported testbed topology if it's a new test case? ### Documentation Signed-off-by: rajshekhar * NOS-3311: Fix MACsec test race and cleanup sync (#678) NOS-3311 tracks MACsec test flakiness caused by races between: * wpa_supplicant/MKA programming MACsec state into Redis (APPL/STATE DB), and * the test harness eagerly reading that state to build `MACSEC_INFO` (via `get_macsec_attr`). This can manifest as exceptions like `KeyError('sak')` when the MACsec egress SA row does not yet exist, even though `MACSEC_PORT_TABLE` already shows `enable_encrypt="true"`. There are also cleanup races where tests check for removal of MACsec DB entries before the background cleanup logic has finished. This PR adds two pieces of synchronization in sonic-mgmt: 1. Ensure MKA establishment before pre-loading MACsec session info for tests 2. Provide a helper to wait for MACsec DB cleanup after disabling MACsec File: `tests/common/macsec/__init__.py` * The `load_macsec_info` fixture (module-scoped, autouse) previously called `load_all_macsec_info()` immediately when MACsec was enabled and a profile was present. That in turn calls `get_macsec_attr()`, which expects APP/STATE DB MACsec SC/SA entries (including `sak`) to be fully programmed. * In environments where MACsec is pre-configured before tests start, this created a race: `MACSEC_PORT_TABLE` might already exist (with `enable_encrypt="true"`), but the egress SA row for the active AN might not yet have been written to APP_DB, leading to `KeyError('sak')` when `macsec_sa["sak"]` is accessed. * Fix: * When MACsec is enabled and a profile is present, the fixture now first *attempts* to resolve the `wait_mka_establish` fixture: ```python try: request.getfixturevalue('wait_mka_establish') except Exception: pass ``` * `wait_mka_establish` is defined in `tests/macsec/conftest.py` and internally uses `check_appl_db` plus `wait_until(...)` to ensure APP/STATE DB MACsec SC/SA tables are populated (including `sak`/`auth_key`/PN relationships) before returning. * If the fixture is not defined (e.g., in other environments or test suites), the code falls back to the previous behavior. * After this synchronization point, if `is_macsec_configured(...)` is true, `load_all_macsec_info()` is called to populate `MACSEC_INFO` for all control links. Otherwise, the original `macsec_setup` flow is triggered. This makes `get_macsec_attr()` execution order consistent with the rest of the MACsec test suite, which already relies on `wait_mka_establish`/`check_appl_db` to guarantee that egress SAs and SAKs exist before validating state. cleanup File: `tests/common/macsec/macsec_config_helper.py` * Add `wait_for_macsec_cleanup(host, interfaces, timeout=90)` and export it via `__all__`. * This helper is designed for tests that: * disable MACsec on one or more interfaces, and then * need to assert that all associated MACsec entries (port, SC, SA) have been automatically removed from Redis before proceeding. * Behavior: * For EOS neighbors, it is a no-op: they do not use Redis DBs and the function returns `True` immediately. * For SONiC hosts, it: * Polls both `APPL_DB` and `STATE_DB` using `redis_get_keys_all_asics` with patterns `MACSEC_*:{interface}*` (APPL_DB) and `MACSEC_*|{interface}*` (STATE_DB). * Aggregates any remaining keys per DB. * Returns `True` as soon as all such keys are gone for the given interfaces, logging total time taken. * If the `timeout` is exceeded, logs a warning, prints a summary of remaining entries, and returns `False`. * This centralizes the logic for “wait until MACsec entries are gone from Redis” instead of having ad hoc sleeps or partial checks in individual tests. * MACsec control-plane actions (via wpa_supplicant and swss/macsecorch) are asynchronous relative to the tests. It is valid for `MACSEC_PORT_TABLE` to show `enable_encrypt="true"` while transmit SAs and their SAKs are still being programmed. * `get_macsec_attr()` assumes that: * APP_DB `MACSEC_EGRESS_SC_TABLE` for `(port, sci)` exists and has a valid `encoding_an`, and * APP_DB `MACSEC_EGRESS_SA_TABLE` for `(port, sci, an)` exists and has a `sak` field. Without synchronization, tests that pre-load `MACSEC_INFO` can hit a window where the SA row does not yet exist and crash with `KeyError('sak')`. * By tying `load_macsec_info` to `wait_mka_establish` where available, we ensure those pre-loads happen only after the expected MACsec state has been fully written to Redis. * Similarly, when disabling MACsec, asynchronous background cleanup can lag behind the test’s expectations. Having a dedicated, reusable `wait_for_macsec_cleanup` helper lets future tests explicitly wait for cleanup completion instead of guessing with sleeps. * Verified that the new fixtures and helpers are imported and wired correctly: * `load_macsec_info` remains `autouse=True` at module scope, so existing MACsec tests automatically benefit from the additional synchronization. * `wait_for_macsec_cleanup` is exported in `__all__` for use by future MACsec tests. * Manually exercised MACsec configuration and teardown flows in a MACsec-enabled testbed (e.g., humm120) to confirm: * MACsec sessions establish successfully and APP/STATE DB contain expected MACsec entries before `load_all_macsec_info` is invoked. * Disabling MACsec followed by `wait_for_macsec_cleanup` results in all MACSEC_* keys being removed from APPL/STATE DB within the timeout window. --- Pull Request opened by [Augment Code](https://www.augmentcode.com/) with guidance from the PR author Signed-off-by: rajshekhar * taking care of review comments - Refine restart_service_with_startlimit_guard to better handle pre-existing StartLimitHit, avoid unnecessary restarts, and apply a shorter backoff when not actually rate-limited. - Narrow the exception in MacsecPlugin to pytest.FixtureLookupError so we only fall back when the wait_mka_establish fixture is truly missing. - Make wait_for_macsec_cleanup more flexible by using a dynamic poll interval and relying on its default timeout from callers. Signed-off-by: rajshekhar --------- Signed-off-by: rajshekhar --- ansible/config_sonic_basedon_testbed.yml | 2 +- ansible/templates/golden_config_db_t2.j2 | 4 +- tests/common/helpers/dut_utils.py | 58 +++++++++++ tests/common/macsec/__init__.py | 20 ++++ tests/common/macsec/macsec_config_helper.py | 108 +++++++++++++++++++- tests/macsec/test_docker_restart.py | 4 +- 6 files changed, 188 insertions(+), 8 deletions(-) diff --git a/ansible/config_sonic_basedon_testbed.yml b/ansible/config_sonic_basedon_testbed.yml index c469fb9f1ee..876dcd45695 100644 --- a/ansible/config_sonic_basedon_testbed.yml +++ b/ansible/config_sonic_basedon_testbed.yml @@ -756,7 +756,7 @@ macsec_profile: "{{ macsec_profile }}" num_asics: "{{ num_asics }}" become: true - when: "('t2' in topo) and (enable_macsec is defined)" + when: "('t2' in topo) and (macsec_profile is defined)" - name: Use minigraph case block: diff --git a/ansible/templates/golden_config_db_t2.j2 b/ansible/templates/golden_config_db_t2.j2 index 45adcea4235..09096b26bc4 100644 --- a/ansible/templates/golden_config_db_t2.j2 +++ b/ansible/templates/golden_config_db_t2.j2 @@ -23,8 +23,7 @@ {% endif %} {%- endfor -%} {% else %} - { - "MACSEC_PROFILE": { + "MACSEC_PROFILE": { "{{macsec_profile}}": { "priority": "{{priority}}", "cipher_suite": "{{cipher_suite}}", @@ -34,6 +33,5 @@ "send_sci": "{{send_sci}}" } } - }, {%- endif -%} } diff --git a/tests/common/helpers/dut_utils.py b/tests/common/helpers/dut_utils.py index 538cf622490..67e16ca9dc7 100644 --- a/tests/common/helpers/dut_utils.py +++ b/tests/common/helpers/dut_utils.py @@ -126,6 +126,64 @@ def clear_failed_flag_and_restart(duthost, container_name): pytest_assert(restarted, "Failed to restart container '{}' after reset-failed was cleared".format(container_name)) +def restart_service_with_startlimit_guard(duthost, service_name, backoff_seconds=30, verify_timeout=180): + """ + Restart a systemd-managed service with StartLimitHit guard. + + Strategy: + 0) Pre-detect StartLimitHit and, if present, skip a failing restart + 1) When not rate-limited, reset-failed to clear stale counters and try restart + 2) If restart fails, rate-limit is detected, or container isn't running: + - 'systemctl reset-failed .service' + - fixed backoff (default 30s when rate-limited, 1s otherwise) + - 'systemctl start .service' + - wait until container is running + + Returns: True when the service is (re)started and running; asserts on failure. + """ + + # 0) Pre-detect StartLimitHit so we can optionally skip a failing restart + pre_rate_limited = is_hitting_start_limit(duthost, service_name) + + if not pre_rate_limited: + # 1) Proactively clear stale failure counters and try a normal restart + duthost.shell( + f"sudo systemctl reset-failed {service_name}.service", + module_ignore_errors=True + ) + ret = duthost.shell( + f"sudo systemctl restart {service_name}.service", + module_ignore_errors=True + ) + rate_limited = is_hitting_start_limit(duthost, service_name) + else: + logger.info( + f"StartLimitHit pre-detected for {service_name}, applying reset-failed and " + f"fixed backoff {backoff_seconds}s before start" + ) + # Force the recovery path below without attempting an immediate restart. + ret = {"rc": 1} + rate_limited = True + + # 2/3) Recovery path: reset-failed + backoff + start if needed + if ret.get("rc", 1) != 0 or rate_limited or not is_container_running(duthost, service_name): + duthost.shell( + f"sudo systemctl reset-failed {service_name}.service", + module_ignore_errors=True + ) + time.sleep(backoff_seconds if rate_limited else 1) + duthost.shell( + f"sudo systemctl start {service_name}.service", + module_ignore_errors=True + ) + pytest_assert( + wait_until(verify_timeout, 1, 0, check_container_state, duthost, service_name, True), + f"{service_name} container did not become running after recovery start" + ) + + return True + + def get_group_program_info(duthost, container_name, group_name): """Gets program names, running status and their pids by analyzing the command output of "docker exec supervisorctl status". Program name diff --git a/tests/common/macsec/__init__.py b/tests/common/macsec/__init__.py index bfcfb9763ab..d26ea8a4f8a 100644 --- a/tests/common/macsec/__init__.py +++ b/tests/common/macsec/__init__.py @@ -122,7 +122,27 @@ def macsec_setup(self, startup_macsec, shutdown_macsec, macsec_feature): @pytest.fixture(scope="module", autouse=True) def load_macsec_info(self, request, macsec_duthost, ctrl_links, macsec_profile, tbinfo): + """Pre-load MACsec session info for all control links. + + If MACsec is enabled and configured for this DUT/profile, wait for + MKA establishment (APP/STATE DB populated with SC/SA, including SAK) + before calling ``load_all_macsec_info``. This avoids races where + ``get_macsec_attr`` hits APP_DB before the egress SA row (and ``sak``) + has been written by wpa_supplicant. + """ + if get_macsec_enable_status(macsec_duthost) and get_macsec_profile(macsec_duthost): + # Ensure MKA sessions are established (SC/SA present in DB) if the + # test environment provides the wait_mka_establish fixture + # (defined in tests/macsec/conftest.py). For environments that do + # not define it, fall back to the original behaviour. + try: + request.getfixturevalue('wait_mka_establish') + except pytest.FixtureLookupError: + # Some environments do not define wait_mka_establish; fall back + # to the original behaviour when the fixture is missing. + pass + if is_macsec_configured(macsec_duthost, macsec_profile, ctrl_links): load_all_macsec_info(macsec_duthost, ctrl_links, tbinfo) else: diff --git a/tests/common/macsec/macsec_config_helper.py b/tests/common/macsec/macsec_config_helper.py index 57c23730077..aba968c4464 100644 --- a/tests/common/macsec/macsec_config_helper.py +++ b/tests/common/macsec/macsec_config_helper.py @@ -1,6 +1,5 @@ import logging import time - from tests.common.macsec.macsec_helper import get_mka_session, getns_prefix, wait_all_complete, \ submit_async_task, load_all_macsec_info from tests.common.macsec.macsec_platform_helper import global_cmd, find_portchannel_from_member, get_portchannel @@ -17,7 +16,8 @@ 'enable_macsec_port', 'disable_macsec_port', 'get_macsec_enable_status', - 'get_macsec_profile' + 'get_macsec_profile', + 'wait_for_macsec_cleanup' ] logger = logging.getLogger(__name__) @@ -219,9 +219,21 @@ def cleanup_macsec_configuration(duthost, ctrl_links, profile_name): submit_async_task(delete_macsec_profile, (d, None, profile_name)) wait_all_complete(timeout=300) + logger.info("Cleanup macsec configuration step3: wait for automatic cleanup") + + # Extract DUT interface names from ctrl_links and wait for automatic + # MACsec cleanup on the DUT side. + interfaces = list(ctrl_links.keys()) + wait_for_macsec_cleanup(duthost, interfaces) + + # Also wait for neighbor devices to complete automatic cleanup for their + # corresponding ports. + for dut_port, nbr in list(ctrl_links.items()): + wait_for_macsec_cleanup(nbr["host"], [nbr["port"]]) + logger.info("Cleanup macsec configuration finished") - # Waiting for all mka session were cleared in all devices + # Waiting for all MKA sessions to be cleared on neighbor devices. for d in devices: if isinstance(d, EosHost): continue @@ -268,3 +280,93 @@ def setup_macsec_configuration(duthost, ctrl_links, profile_name, default_priori # Load the MACSEC_INFO, to have data of all macsec sessions load_all_macsec_info(duthost, ctrl_links, tbinfo) + + +def wait_for_macsec_cleanup(host, interfaces, timeout=90): + """Wait for MACsec daemon to automatically clean up all MACsec entries. + + This function implements proper synchronization to wait for the automatic + cleanup process to complete, preserving the intended MACsec cleanup behavior. + + Args: + host: SONiC DUT or neighbor host object + interfaces: List of interface names to check + timeout: Maximum time to wait in seconds for MACsec cleanup to finish (default: 90). + + Returns: + bool: True if cleanup completed, False if timeout + """ + if isinstance(host, EosHost): + # EOS hosts don't use Redis databases + logger.info("EOS host detected, skipping Redis cleanup verification") + return True + + logger.info(f"Waiting for automatic MACsec cleanup (timeout: {timeout}s)") + + start_time = time.time() + # Poll at most ~10 times over the full timeout, capped at 10 seconds between checks. + poll_interval = min(10, max(1, timeout / 10.0)) + + # We only care about APPL_DB and STATE_DB for MACsec tables. Instead of + # trying to reverse-engineer numeric DB IDs from CONFIG_DB, rely on + # sonic-db-cli with logical DB names and the same namespace logic used + # elsewhere in MACsec helpers. + + while time.time() - start_time < timeout: + all_clean = True + remaining_entries = {} + + for interface in interfaces: + ns_prefix = getns_prefix(host, interface) + + for db_name, sep in (("APPL_DB", ":"), ("STATE_DB", "|")): + pattern = f"MACSEC_*{sep}{interface}*" + cmd = f"sonic-db-cli {ns_prefix} {db_name} KEYS '{pattern}'" + + try: + result = host.command(cmd, verbose=False) + out_lines = result.get("stdout_lines", []) + except Exception as e: + logger.warning( + "Failed to query MACsec keys on host %s, DB %s, interface %s: %r", + getattr(host, 'hostname', host), + db_name, + interface, + e, + ) + # If we cannot query Redis for this DB/interface, be + # conservative and assume cleanup is not complete yet. + all_clean = False + continue + + keys = [k.strip() for k in out_lines if k.strip()] + if keys: + all_clean = False + remaining_entries.setdefault((db_name, interface), []).extend(keys) + + elapsed = time.time() - start_time + + if all_clean: + logger.info( + f"Automatic MACsec cleanup completed successfully in {elapsed:.1f}s" + ) + return True + + # Log progress every 30 seconds to reduce verbosity + if int(elapsed) % 30 == 0 and elapsed > 0: + logger.info(f"Still waiting for cleanup... ({elapsed:.0f}s elapsed)") + + time.sleep(poll_interval) + + # Timeout reached + elapsed = time.time() - start_time + logger.warning(f"Automatic MACsec cleanup timeout after {elapsed:.1f}s") + + # Log summary of remaining entries + total_remaining = sum(len(entries) for entries in remaining_entries.values()) + if total_remaining > 0: + logger.warning( + f" {total_remaining} MACsec entries still remain after timeout" + ) + + return False diff --git a/tests/macsec/test_docker_restart.py b/tests/macsec/test_docker_restart.py index d7535ab6108..0574dc61da1 100644 --- a/tests/macsec/test_docker_restart.py +++ b/tests/macsec/test_docker_restart.py @@ -3,6 +3,8 @@ from tests.common.utilities import wait_until from tests.common.macsec.macsec_helper import check_appl_db +from tests.common.helpers.dut_utils import restart_service_with_startlimit_guard + logger = logging.getLogger(__name__) @@ -17,6 +19,6 @@ def test_restart_macsec_docker(duthosts, ctrl_links, policy, cipher_suite, send_ duthost = duthosts[enum_rand_one_per_hwsku_macsec_frontend_hostname] logger.info(duthost.shell(cmd="docker ps", module_ignore_errors=True)['stdout']) - duthost.restart_service("macsec") + restart_service_with_startlimit_guard(duthost, "macsec", backoff_seconds=35, verify_timeout=180) logger.info(duthost.shell(cmd="docker ps", module_ignore_errors=True)['stdout']) assert wait_until(300, 6, 12, check_appl_db, duthost, ctrl_links, policy, cipher_suite, send_sci) From 7330c491cce6e0580a03d98caac21f79d3522ae4 Mon Sep 17 00:00:00 2001 From: Nanma Purushotam Date: Mon, 9 Feb 2026 16:17:35 -0800 Subject: [PATCH 19/43] [generic_config_updater] Fix ethernet test port availability and error handling (#22082) Description of PR Summary: Fixes ethernet interface test failures due to port availability issues and routeCheck errors on multi-ASIC platforms The below PR is also needed to fix the test completely: PR for FEC related issue - #21026 Type of change Bug fix Testbed and Framework(new/improvement) New Test case Skipped for non-supported platforms Test case improvement What is the motivation for this PR? Ethernet interface tests would fail when all ethernet ports were members of PortChannels and no free ports were available for testing. Additionally, tests were failing during teardown with routeCheck errors, particularly on multi-ASIC platforms, where port configuration changes (speed, FEC, admin status) triggered temporary routing inconsistencies that were flagged by the routeCheck monitoring service. How did you do it? Enhanced port availability logic: Added get_test_port() to automatically remove a port from its PortChannel if no free ports are available Added remove_port_from_portchannel() helper function Port removal is temporary - the ensure_dut_readiness fixture's rollback mechanism automatically restores the original configuration Added MTU test protection: checks for free ports without removing from PortChannel (remove_from_portchannel=False), and skips test if none available to avoid routing convergence issues Added loganalyzer fixture (ignore_expected_loganalyzer_exceptions_lag) to suppress expected routeCheck errors that occur during port configuration changes. The fixture ignores: orchagent port speed errors routeCheck temporary failures with unaccounted routes How did you verify/test it? Ran all ethernet interface tests on multi-ASIC linecard platforms where all ports were in PortChannels Verified that tests successfully complete without routeCheck failures during teardown Confirmed that port removal and restoration works correctly via rollback mechanism Signed-off-by: Nanma Purushotam * Trigger CI checks Signed-off-by: Nanma Purushotam * Updated conftest, and removed test based loganalyzer Signed-off-by: Nanma Purushotam --- tests/generic_config_updater/conftest.py | 1 + .../test_eth_interface.py | 119 +++++++++++++++--- 2 files changed, 105 insertions(+), 15 deletions(-) diff --git a/tests/generic_config_updater/conftest.py b/tests/generic_config_updater/conftest.py index 2481fad3879..534f5dc3518 100644 --- a/tests/generic_config_updater/conftest.py +++ b/tests/generic_config_updater/conftest.py @@ -108,6 +108,7 @@ def ignore_expected_loganalyzer_exceptions(request, duthosts, loganalyzer): ".*ERR kernel.*Reset adapter.*", # Valid test_portchannel_interface replace mtu ".*ERR swss[0-9]*#orchagent: :- getPortOperSpeed.*", # Valid test_portchannel_interface replace mtu ".*ERR systemd.*Failed to start Host core file uploader daemon.*", # Valid test_syslog + r".*ERR monit\[\d+\]: 'routeCheck' status failed \(255\) -- Failure results:.*", # sonic-swss/orchagent/crmorch.cpp ".*ERR swss[0-9]*#orchagent.*getResAvailableCounters.*", # test_monitor_config diff --git a/tests/generic_config_updater/test_eth_interface.py b/tests/generic_config_updater/test_eth_interface.py index df4691820f4..3ef4d699e13 100644 --- a/tests/generic_config_updater/test_eth_interface.py +++ b/tests/generic_config_updater/test_eth_interface.py @@ -20,17 +20,17 @@ @pytest.fixture(autouse=True) -def ensure_dut_readiness(duthosts, rand_one_dut_hostname): +def ensure_dut_readiness(duthosts, rand_one_dut_front_end_hostname): """ Setup/teardown fixture for each ethernet test rollback to check if it goes back to starting config Args: duthosts: list of DUTs - rand_one_dut_hostname: The fixture returns a randomly selected DUT hostname + rand_one_dut_front_end_hostname: The fixture returns a randomly selected frontend DUT hostname """ - duthost = duthosts[rand_one_dut_hostname] + duthost = duthosts[rand_one_dut_front_end_hostname] create_checkpoint(duthost) yield @@ -112,6 +112,27 @@ def check_interface_status(duthost, field, interface='Ethernet0'): return status +def remove_port_from_portchannel(duthost, port, portchannel, namespace=None): + """ + Removes a port from its PortChannel membership + + Args: + duthost: DUT host object under test + port: Port name to remove + portchannel: PortChannel name + namespace: DUT asic namespace + """ + namespace_prefix = '' if namespace is None else '-n ' + namespace + cmd = 'config portchannel {} member del {} {}'.format(namespace_prefix, portchannel, port) + logger.info("Removing {} from {} in namespace {}".format( + port, portchannel, namespace or 'default')) + output = duthost.shell(cmd) + pytest_assert( + output['rc'] == 0, + "Failed to remove {} from {}: {}".format(port, portchannel, output.get('stderr', ''))) + return True + + def get_ethernet_port_not_in_portchannel(duthost, namespace=None): """ Returns the name of an ethernet port which is not a member of a port channel @@ -147,6 +168,62 @@ def get_ethernet_port_not_in_portchannel(duthost, namespace=None): return port_name +def get_test_port(duthost, namespace=None, remove_from_portchannel=True): + """ + Returns an available ethernet port for testing. + If no free ports exist and remove_from_portchannel=True, removes a port from a PortChannel. + The port will be restored by the ensure_dut_readiness fixture's rollback mechanism. + + Args: + duthost: DUT host object under test + namespace: DUT asic namespace + remove_from_portchannel: If True, remove a port from PortChannel if no free ports available + + Returns: + Port name string, or empty string if no suitable port found + """ + # First try to get a port not in a PortChannel + port = get_ethernet_port_not_in_portchannel(duthost, namespace=namespace) + if port: + logger.info("Found available port: {}".format(port)) + return port + + if not remove_from_portchannel: + logger.warning("No available ports and remove_from_portchannel=False") + return "" + + # If no free port, find one in a PortChannel and remove it + logger.info("No free ports available, attempting to remove a port from PortChannel") + config_facts = duthost.config_facts( + host=duthost.hostname, + source="running", + verbose=False, + namespace=namespace + )['ansible_facts'] + + if 'PORTCHANNEL_MEMBER' not in config_facts or 'PORT' not in config_facts: + logger.warning("No PortChannel members or ports found") + return "" + + port_channel_member_facts = config_facts['PORTCHANNEL_MEMBER'] + + # Find a suitable port to remove (prefer Ext role ports) + for portchannel in list(port_channel_member_facts.keys()): + for member in list(port_channel_member_facts[portchannel].keys()): + port_role = config_facts['PORT'].get(member, {}).get('role') + if port_role and port_role != 'Ext': + continue # Skip internal/fabric ports + + # Found a candidate - remove it from the PortChannel + logger.info("Removing {} from {} for testing (will be restored by rollback)".format( + member, portchannel)) + remove_port_from_portchannel(duthost, member, portchannel, namespace=namespace) + return member + + logger.warning("No suitable ports found even in PortChannels") + return "" + + def get_port_speeds_for_test(duthost, port): """ Get the speeds parameters for case test_update_speed, including 2 valid speeds and 1 invalid speed @@ -195,7 +272,8 @@ def test_remove_lanes(duthosts, rand_one_dut_front_end_hostname, duthost = duthosts[rand_one_dut_front_end_hostname] asic_namespace = None if enum_rand_one_frontend_asic_index is None else \ 'asic{}'.format(enum_rand_one_frontend_asic_index) - port = get_ethernet_port_not_in_portchannel(duthost, namespace=asic_namespace) + port = get_test_port(duthost, namespace=asic_namespace) + pytest_assert(port, "No available ethernet ports on this ASIC") json_patch = [ { "op": "remove", @@ -221,7 +299,8 @@ def test_replace_lanes(duthosts, rand_one_dut_front_end_hostname, ensure_dut_rea duthost = duthosts[rand_one_dut_front_end_hostname] asic_namespace = None if enum_rand_one_frontend_asic_index is None else \ 'asic{}'.format(enum_rand_one_frontend_asic_index) - port = get_ethernet_port_not_in_portchannel(duthost, namespace=asic_namespace) + port = get_test_port(duthost, namespace=asic_namespace) + pytest_assert(port, "No available ethernet ports on this ASIC") cur_lanes = check_interface_status(duthost, "Lanes", port) cur_lanes = cur_lanes.split(",") cur_lanes.sort() @@ -253,10 +332,14 @@ def test_replace_mtu(duthosts, rand_one_dut_front_end_hostname, ensure_dut_readi duthost = duthosts[rand_one_dut_front_end_hostname] asic_namespace = None if enum_rand_one_frontend_asic_index is None else \ 'asic{}'.format(enum_rand_one_frontend_asic_index) - # Can't directly change mtu of the port channel member - # So find a ethernet port that are not in a port channel - port = get_ethernet_port_not_in_portchannel(duthost, namespace=asic_namespace) - pytest_assert(port, "No available ethernet ports, all ports are in port channels.") + + # Get a test port - check without removing from PortChannel to avoid routing issues + port = get_test_port(duthost, namespace=asic_namespace, remove_from_portchannel=False) + + if not port: + # MTU changes on ports removed from PortChannel can cause routing convergence issues + # Skip this test to avoid teardown failures + pytest.skip("No free ports available. Skipping MTU test to avoid routing issues from PortChannel changes.") target_mtu = "1514" json_patch = [ { @@ -287,7 +370,8 @@ def test_toggle_pfc_asym(duthosts, rand_one_dut_front_end_hostname, ensure_dut_r duthost = duthosts[rand_one_dut_front_end_hostname] asic_namespace = None if enum_rand_one_frontend_asic_index is None else \ 'asic{}'.format(enum_rand_one_frontend_asic_index) - port = get_ethernet_port_not_in_portchannel(duthost, namespace=asic_namespace) + port = get_test_port(duthost, namespace=asic_namespace) + pytest_assert(port, "No available ethernet ports on this ASIC") json_patch = [ { "op": "replace", @@ -320,7 +404,8 @@ def test_replace_fec(duthosts, rand_one_dut_front_end_hostname, ensure_dut_readi 'asic{}'.format(enum_rand_one_frontend_asic_index) namespace_prefix = '' if asic_namespace is None else '-n ' + asic_namespace intf_init_status = duthost.get_interfaces_status() - port = get_ethernet_port_not_in_portchannel(duthost, namespace=asic_namespace) + port = get_test_port(duthost, namespace=asic_namespace) + pytest_assert(port, "No available ethernet ports on this ASIC") intf_init_fec_oper = get_fec_oper(duthost, port) json_patch = [ { @@ -363,7 +448,8 @@ def test_update_invalid_index(duthosts, rand_one_dut_front_end_hostname, ensure_ duthost = duthosts[rand_one_dut_front_end_hostname] asic_namespace = None if enum_rand_one_frontend_asic_index is None else \ 'asic{}'.format(enum_rand_one_frontend_asic_index) - port = get_ethernet_port_not_in_portchannel(duthost, namespace=asic_namespace) + port = get_test_port(duthost, namespace=asic_namespace) + pytest_assert(port, "No available ethernet ports on this ASIC") json_patch = [ { "op": "replace", @@ -433,7 +519,8 @@ def test_update_speed(duthosts, rand_one_dut_front_end_hostname, ensure_dut_read duthost = duthosts[rand_one_dut_front_end_hostname] asic_namespace = None if enum_rand_one_frontend_asic_index is None else \ 'asic{}'.format(enum_rand_one_frontend_asic_index) - port = get_ethernet_port_not_in_portchannel(duthost, namespace=asic_namespace) + port = get_test_port(duthost, namespace=asic_namespace) + pytest_assert(port, "No available ethernet ports on this ASIC") speed_params = get_port_speeds_for_test(duthost, port) for speed, is_valid in speed_params: json_patch = [ @@ -468,7 +555,8 @@ def test_update_description(duthosts, rand_one_dut_front_end_hostname, ensure_du duthost = duthosts[rand_one_dut_front_end_hostname] asic_namespace = None if enum_rand_one_frontend_asic_index is None else \ 'asic{}'.format(enum_rand_one_frontend_asic_index) - port = get_ethernet_port_not_in_portchannel(duthost, namespace=asic_namespace) + port = get_test_port(duthost, namespace=asic_namespace) + pytest_assert(port, "No available ethernet ports on this ASIC") json_patch = [ { "op": "replace", @@ -495,7 +583,8 @@ def test_eth_interface_admin_change(duthosts, rand_one_dut_front_end_hostname, a duthost = duthosts[rand_one_dut_front_end_hostname] asic_namespace = None if enum_rand_one_frontend_asic_index is None else \ 'asic{}'.format(enum_rand_one_frontend_asic_index) - port = get_ethernet_port_not_in_portchannel(duthost, namespace=asic_namespace) + port = get_test_port(duthost, namespace=asic_namespace) + pytest_assert(port, "No available ethernet ports on this ASIC") json_patch = [ { "op": "add", From f32b75403daed445e0d6d92a84a9fd51d8061bac Mon Sep 17 00:00:00 2001 From: Cliff Chen <125133451+wiperi@users.noreply.github.com> Date: Tue, 10 Feb 2026 12:28:11 +1100 Subject: [PATCH 20/43] Fix serial device prefix fallback value (#22241) Fix incorrect fallback value for serial device prefix in _get_serial_device_prefix(). Changed from /dev/ttyUSB- to /dev/ttyUSB to generate correct device paths (e.g., /dev/ttyUSB1 instead of /dev/ttyUSB-1). What is the motivation for this PR? When udevprefix.conf cannot be read, the fallback value /dev/ttyUSB- generates invalid device paths. How did you do it? Changed fallback from /dev/ttyUSB- to /dev/ttyUSB and updated related docstrings/comments. How did you verify/test it? Code review. Signed-off-by: cliffchen --- tests/common/devices/sonic.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/common/devices/sonic.py b/tests/common/devices/sonic.py index d9e5e48ccbc..cd80ccb207a 100644 --- a/tests/common/devices/sonic.py +++ b/tests/common/devices/sonic.py @@ -3009,10 +3009,10 @@ def _get_serial_device_prefix(self) -> str: Get the serial device prefix for the platform. Returns: - str: The device prefix (e.g., "/dev/C0-", "/dev/ttyUSB-") + str: The device prefix (e.g., "/dev/C0-", "/dev/ttyUSB") """ # Reads udevprefix.conf from the platform directory to determine the correct device prefix - # Falls back to /dev/ttyUSB- if the config file doesn't exist + # Falls back to /dev/ttyUSB if the config file doesn't exist script = ''' from sonic_py_common import device_info import os @@ -3032,8 +3032,8 @@ def _get_serial_device_prefix(self) -> str: res: ShellResult = self.shell(cmd, module_ignore_errors=True) if res['rc'] != 0 or not res['stdout'].strip(): - logging.warning("Failed to get serial device prefix, using default /dev/ttyUSB-") - device_prefix = "/dev/ttyUSB-" + logging.warning("Failed to get serial device prefix, using default /dev/ttyUSB") + device_prefix = "/dev/ttyUSB" else: device_prefix = res['stdout'].strip() @@ -3047,7 +3047,7 @@ def _get_serial_device_path(self, port: int) -> str: port: Port number (e.g., 1, 2) Returns: - str: The full device path (e.g., "/dev/C0-1", "/dev/ttyUSB-1") + str: The full device path (e.g., "/dev/C0-1", "/dev/ttyUSB1") """ device_prefix = self._get_serial_device_prefix() return f"{device_prefix}{port}" From 4af314c4cb62b6435c7682ff873dc5b29c490d1e Mon Sep 17 00:00:00 2001 From: Yan Mo Date: Mon, 9 Feb 2026 17:30:31 -0800 Subject: [PATCH 21/43] Enable test_po_update with ipv6-only topo (#21910) What is the motivation for this PR? Enable test_po_update with ipv6-only topo How did you do it? Added steps for ipv6 How did you verify/test it? Ran on ipv6-only topo testbed --- .../tests_mark_conditions.yaml | 4 -- tests/pc/test_po_update.py | 57 ++++++++++++++----- 2 files changed, 43 insertions(+), 18 deletions(-) diff --git a/tests/common/plugins/conditional_mark/tests_mark_conditions.yaml b/tests/common/plugins/conditional_mark/tests_mark_conditions.yaml index 54618f1f07f..9e440468935 100644 --- a/tests/common/plugins/conditional_mark/tests_mark_conditions.yaml +++ b/tests/common/plugins/conditional_mark/tests_mark_conditions.yaml @@ -3577,10 +3577,6 @@ pc/test_po_update.py::test_po_update: reason: "Skip test due to there is no portchannel or no portchannel member exists in current topology." conditions: - "len(minigraph_portchannels) == 0 or len(minigraph_portchannels[list(minigraph_portchannels.keys())[0]]['members']) == 0" - xfail: - reason: "xfail for IPv6-only topologies, need to add support for IPv6-only - https://github.com/sonic-net/sonic-mgmt/issues/20729" - conditions: - - "https://github.com/sonic-net/sonic-mgmt/issues/20729 and '-v6-' in topo_name" pc/test_po_update.py::test_po_update_io_no_loss: skip: diff --git a/tests/pc/test_po_update.py b/tests/pc/test_po_update.py index 63a4061c529..750da2e9370 100644 --- a/tests/pc/test_po_update.py +++ b/tests/pc/test_po_update.py @@ -73,7 +73,9 @@ def _wait_until_pc_members_removed(asichost, pc_names): pytest.fail("Portchannel members are not removed from {}".format(pc_names)) -def has_bgp_neighbors(duthost, portchannel): +def has_bgp_neighbors(duthost, portchannel, is_ipv6=False): + if is_ipv6: + return duthost.shell("show ipv6 int | grep {} | awk '{{print $4}}'".format(portchannel))['stdout'] != 'N/A' return duthost.shell("show ip int | grep {} | awk '{{print $4}}'".format(portchannel))['stdout'] != 'N/A' @@ -96,17 +98,37 @@ def test_po_update(duthosts, enum_rand_one_per_hwsku_frontend_hostname, enum_fro portchannel = None portchannel_members = None + is_ipv6 = False for portchannel in port_channels_data: logging.info('Trying to get PortChannel: {} for test'.format(portchannel)) if int_facts['ansible_interface_facts'][portchannel].get('ipv4'): portchannel_members = port_channels_data[portchannel] break + elif int_facts['ansible_interface_facts'][portchannel].get('ipv6'): + # Check for non-link-local IPv6 address + for ipv6_info in int_facts['ansible_interface_facts'][portchannel]['ipv6']: + if not ipaddress.ip_address(ipv6_info['address']).is_link_local: + portchannel_members = port_channels_data[portchannel] + is_ipv6 = True + break + if portchannel_members: + break pytest_assert(portchannel and portchannel_members, 'Can not get PortChannel interface for test') tmp_portchannel = "PortChannel999" - # Initialize portchannel_ip and portchannel_members - portchannel_ip = int_facts['ansible_interface_facts'][portchannel]['ipv4']['address'] + # Initialize portchannel_ip and prefix_len based on IP version + if is_ipv6: + # Find non-link-local IPv6 address + for ipv6_info in int_facts['ansible_interface_facts'][portchannel]['ipv6']: + if not ipaddress.ip_address(ipv6_info['address']).is_link_local: + portchannel_ip = ipv6_info['address'] + prefix_len = str(ipv6_info['prefix']) + break + else: + portchannel_ip = int_facts['ansible_interface_facts'][portchannel]['ipv4']['address'] + prefix_len = "31" + bgp_state_key = 'ipv6_idle' if is_ipv6 else 'ipv4_idle' # Initialize flags remove_portchannel_members = False @@ -118,6 +140,7 @@ def test_po_update(duthosts, enum_rand_one_per_hwsku_frontend_hostname, enum_fro logging.info("portchannel=%s" % portchannel) logging.info("portchannel_ip=%s" % portchannel_ip) logging.info("portchannel_members=%s" % portchannel_members) + logging.info("is_ipv6=%s" % is_ipv6) try: # Step 1: Remove portchannel members from portchannel @@ -126,15 +149,15 @@ def test_po_update(duthosts, enum_rand_one_per_hwsku_frontend_hostname, enum_fro remove_portchannel_members = True # Step 2: Remove portchannel ip from portchannel - asichost.config_ip_intf(portchannel, portchannel_ip + "/31", "remove") + asichost.config_ip_intf(portchannel, portchannel_ip + "/" + prefix_len, "remove") remove_portchannel_ip = True time.sleep(30) int_facts = asichost.interface_facts()['ansible_facts'] pytest_assert(not int_facts['ansible_interface_facts'][portchannel]['link']) pytest_assert( - has_bgp_neighbors(duthost, portchannel) and - wait_until(120, 10, 0, asichost.check_bgp_statistic, 'ipv4_idle', 1) + has_bgp_neighbors(duthost, portchannel, is_ipv6) and + wait_until(120, 10, 0, asichost.check_bgp_statistic, bgp_state_key, 1) or not wait_until(10, 10, 0, pc_active, asichost, portchannel)) # Step 3: Create tmp portchannel @@ -147,22 +170,28 @@ def test_po_update(duthosts, enum_rand_one_per_hwsku_frontend_hostname, enum_fro add_tmp_portchannel_members = True # Step 5: Add portchannel ip to tmp portchannel - asichost.config_ip_intf(tmp_portchannel, portchannel_ip + "/31", "add") + asichost.config_ip_intf(tmp_portchannel, portchannel_ip + "/" + prefix_len, "add") int_facts = asichost.interface_facts()['ansible_facts'] - pytest_assert(int_facts['ansible_interface_facts'][tmp_portchannel]['ipv4']['address'] == portchannel_ip) + if is_ipv6: + tmp_pc_ipv6_addrs = [ipv6_info['address'] + for ipv6_info in int_facts['ansible_interface_facts'][tmp_portchannel].get('ipv6', [])] + pytest_assert(portchannel_ip in tmp_pc_ipv6_addrs, + "IPv6 address {} not found on {}".format(portchannel_ip, tmp_portchannel)) + else: + pytest_assert(int_facts['ansible_interface_facts'][tmp_portchannel]['ipv4']['address'] == portchannel_ip) add_tmp_portchannel_ip = True time.sleep(30) int_facts = asichost.interface_facts()['ansible_facts'] pytest_assert(int_facts['ansible_interface_facts'][tmp_portchannel]['link']) pytest_assert( - has_bgp_neighbors(duthost, tmp_portchannel) and - wait_until(120, 10, 0, asichost.check_bgp_statistic, 'ipv4_idle', 0) + has_bgp_neighbors(duthost, tmp_portchannel, is_ipv6) and + wait_until(120, 10, 0, asichost.check_bgp_statistic, bgp_state_key, 0) or wait_until(10, 10, 0, pc_active, asichost, tmp_portchannel)) finally: # Recover all states if add_tmp_portchannel_ip: - asichost.config_ip_intf(tmp_portchannel, portchannel_ip + "/31", "remove") + asichost.config_ip_intf(tmp_portchannel, portchannel_ip + "/" + prefix_len, "remove") time.sleep(5) if add_tmp_portchannel_members: @@ -173,15 +202,15 @@ def test_po_update(duthosts, enum_rand_one_per_hwsku_frontend_hostname, enum_fro if create_tmp_portchannel: asichost.config_portchannel(tmp_portchannel, "del") if remove_portchannel_ip: - asichost.config_ip_intf(portchannel, portchannel_ip + "/31", "add") + asichost.config_ip_intf(portchannel, portchannel_ip + "/" + prefix_len, "add") if remove_portchannel_members: for member in portchannel_members: asichost.config_portchannel_member(portchannel, member, "add") time.sleep(5) pytest_assert( - has_bgp_neighbors(duthost, portchannel) and - wait_until(120, 10, 0, asichost.check_bgp_statistic, 'ipv4_idle', 0) + has_bgp_neighbors(duthost, portchannel, is_ipv6) and + wait_until(120, 10, 0, asichost.check_bgp_statistic, bgp_state_key, 0) or wait_until(10, 10, 0, pc_active, asichost, portchannel)) From 0048c8e4751bae57af7295a08c92700c274db974 Mon Sep 17 00:00:00 2001 From: Yan Mo Date: Mon, 9 Feb 2026 17:34:01 -0800 Subject: [PATCH 22/43] Enable test_static_route for ipv6 only topo (#21765) What is the motivation for this PR? To enable ipv6 only topo for test_static_route How did you do it? Have a check for ipv6 and use appropriate prefix for it. How did you verify/test it? Tested on ipv6 only testbed to verify --- .../tests_mark_conditions.yaml | 8 ------- tests/route/test_static_route.py | 24 +++++++++++++------ 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/tests/common/plugins/conditional_mark/tests_mark_conditions.yaml b/tests/common/plugins/conditional_mark/tests_mark_conditions.yaml index 9e440468935..96743995faf 100644 --- a/tests/common/plugins/conditional_mark/tests_mark_conditions.yaml +++ b/tests/common/plugins/conditional_mark/tests_mark_conditions.yaml @@ -4333,20 +4333,12 @@ route/test_static_route.py: - "platform in ['x86_64-8122_64eh_o-r0', 'x86_64-8122_64ehf_o-r0']" route/test_static_route.py::test_static_route[: - skip: - reason: "Skip for IPv6-only topologies" - conditions: - - "'-v6-' in topo_name" xfail: reason: "xfail for scale topology, issue https://github.com/sonic-net/sonic-buildimage/issues/24537" conditions: - "https://github.com/sonic-net/sonic-buildimage/issues/24537 and 't0-isolated-d256u256s2' in topo_name" route/test_static_route.py::test_static_route_ecmp[: - skip: - reason: "Skip for IPv6-only topologies" - conditions: - - "'-v6-' in topo_name" xfail: reason: "xfail for scale topology, issue https://github.com/sonic-net/sonic-buildimage/issues/24537" conditions: diff --git a/tests/route/test_static_route.py b/tests/route/test_static_route.py index ba417bc750b..4f129ba195c 100644 --- a/tests/route/test_static_route.py +++ b/tests/route/test_static_route.py @@ -15,7 +15,7 @@ from tests.common.dualtor.mux_simulator_control import mux_server_url # noqa F811 from tests.common.dualtor.dual_tor_utils import show_muxcable_status from tests.common.dualtor.mux_simulator_control import toggle_all_simulator_ports_to_rand_selected_tor_m # noqa F811 -from tests.common.utilities import wait_until, get_intf_by_sub_intf +from tests.common.utilities import wait_until, get_intf_by_sub_intf, is_ipv6_only_topology from tests.common.utilities import get_neighbor_ptf_port_list from tests.common.helpers.assertions import pytest_assert from tests.common.helpers.assertions import pytest_require @@ -471,6 +471,11 @@ def get_nexthops(duthost, tbinfo, ipv6=False, count=1): vlan_intf = get_vlan_interface_info(duthost, tbinfo, vlan_if_name, "ipv6") else: vlan_intf = get_vlan_interface_info(duthost, tbinfo, vlan_if_name, "ipv4") + + # Check if the requested IP version is available (e.g., IPv6-only topology has no IPv4) + if not vlan_intf or 'prefixlen' not in vlan_intf: + return None, None, None, None + prefix_len = vlan_intf['prefixlen'] is_backend_topology = mg_facts.get(constants.IS_BACKEND_TOPOLOGY_KEY, False) @@ -511,9 +516,12 @@ def test_static_route(rand_selected_dut, rand_unselected_dut, ptfadapter, ptfhos toggle_all_simulator_ports_to_rand_selected_tor_m, is_route_flow_counter_supported): # noqa F811 duthost = rand_selected_dut unselected_duthost = rand_unselected_dut - prefix_len, nexthop_addrs, nexthop_devs, nexthop_interfaces = get_nexthops(duthost, tbinfo) - run_static_route_test(duthost, unselected_duthost, ptfadapter, ptfhost, tbinfo, "1.1.1.0/24", - nexthop_addrs, prefix_len, nexthop_devs, nexthop_interfaces, is_route_flow_counter_supported) + ipv6 = is_ipv6_only_topology(tbinfo) + prefix = "2000:1::/64" if ipv6 else "1.1.1.0/24" + prefix_len, nexthop_addrs, nexthop_devs, nexthop_interfaces = get_nexthops(duthost, tbinfo, ipv6=ipv6) + run_static_route_test(duthost, unselected_duthost, ptfadapter, ptfhost, tbinfo, prefix, + nexthop_addrs, prefix_len, nexthop_devs, nexthop_interfaces, + is_route_flow_counter_supported, ipv6=ipv6) @pytest.mark.disable_loganalyzer @@ -522,10 +530,12 @@ def test_static_route_ecmp(rand_selected_dut, rand_unselected_dut, ptfadapter, p toggle_all_simulator_ports_to_rand_selected_tor_m, is_route_flow_counter_supported): # noqa F811 duthost = rand_selected_dut unselected_duthost = rand_unselected_dut - prefix_len, nexthop_addrs, nexthop_devs, nexthop_interfaces = get_nexthops(duthost, tbinfo, count=3) - run_static_route_test(duthost, unselected_duthost, ptfadapter, ptfhost, tbinfo, "2.2.2.0/24", + ipv6 = is_ipv6_only_topology(tbinfo) + prefix = "2000:2::/64" if ipv6 else "2.2.2.0/24" + prefix_len, nexthop_addrs, nexthop_devs, nexthop_interfaces = get_nexthops(duthost, tbinfo, ipv6=ipv6, count=3) + run_static_route_test(duthost, unselected_duthost, ptfadapter, ptfhost, tbinfo, prefix, nexthop_addrs, prefix_len, nexthop_devs, nexthop_interfaces, - is_route_flow_counter_supported, config_reload_test=True) + is_route_flow_counter_supported, ipv6=ipv6, config_reload_test=True) def test_static_route_ipv6(rand_selected_dut, rand_unselected_dut, ptfadapter, ptfhost, tbinfo, From 4d083ff5fa6cdb80da0eb12cd1060357723ce8f9 Mon Sep 17 00:00:00 2001 From: Yan Mo Date: Mon, 9 Feb 2026 17:35:03 -0800 Subject: [PATCH 23/43] Remove xfail conditions for test_everflow_per_interface[ipv6-erspan_ipv6-default] since it is duplicated with skip condition (#21759) What is the motivation for this PR? Remove duplicated xfail, since it should already been cover by the skip condition. So conditions can be more clear, less confusion and less maintenance. --- .../plugins/conditional_mark/tests_mark_conditions.yaml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/tests/common/plugins/conditional_mark/tests_mark_conditions.yaml b/tests/common/plugins/conditional_mark/tests_mark_conditions.yaml index 96743995faf..6e3f8f20943 100644 --- a/tests/common/plugins/conditional_mark/tests_mark_conditions.yaml +++ b/tests/common/plugins/conditional_mark/tests_mark_conditions.yaml @@ -1771,12 +1771,6 @@ everflow/test_everflow_per_interface.py::test_everflow_per_interface[ipv6-erspan reason: "Skip everflow per interface IPv6 test on unsupported platforms x86_64-nvidia_sn5640-r0." conditions: - "platform in ['x86_64-nvidia_sn5640-r0']" - xfail: - reason: "xfail for IPv6-only topologies, need support for IPv6 bgp. Or test case has issue on the t0-isolated-d256u256s2 topo." - conditions_logical_operator: or - conditions: - - "https://github.com/sonic-net/sonic-mgmt/issues/19096 and '-v6-' in topo_name" - - "'t0-isolated-d256u256s2' in topo_name and platform in ['x86_64-nvidia_sn5640-r0']" everflow/test_everflow_per_interface.py::test_everflow_per_interface[ipv6-m0_l3_scenario]: skip: From 277a544e73e800a0a4e341eb4a58aa6ccb2580ea Mon Sep 17 00:00:00 2001 From: Yawen Date: Tue, 10 Feb 2026 13:15:59 +1100 Subject: [PATCH 24/43] Fix test_ipinip teardown issue (#22276) What is the motivation for this PR? The previous test_ipinip teardown could leave the dut in an unhealthy state after the test finishes, causing several docker containers to remain down and impacting subsequent tests. How did you do it? Updated the test_ipinip teardown logic to restore the dut by performing a golden config reload. How did you verify/test it? Ran test_ipinip and passed. --- tests/dualtor/test_ipinip.py | 22 +++------------------- 1 file changed, 3 insertions(+), 19 deletions(-) diff --git a/tests/dualtor/test_ipinip.py b/tests/dualtor/test_ipinip.py index 3a383b799e4..32066eada46 100644 --- a/tests/dualtor/test_ipinip.py +++ b/tests/dualtor/test_ipinip.py @@ -16,6 +16,7 @@ from ptf import mask from ptf import testutils from scapy.all import Ether, IP +from tests.common import config_reload from tests.common.dualtor.dual_tor_mock import * # noqa: F403 from tests.common.dualtor.dual_tor_utils import get_t1_ptf_ports from tests.common.dualtor.dual_tor_utils import rand_selected_interface # noqa: F401 @@ -240,25 +241,8 @@ def setup_uplink(rand_selected_dut, tbinfo, enable_feature_autorestart): yield mg_facts['minigraph_ptf_indices'][up_member] - # Startup the uplinks that were shutdown - for pc in portchannels: - if pc != up_portchannel: - cmd = "config interface startup {}".format(pc) - rand_selected_dut.shell(cmd) - # Restore the LAG - if len(pc_members) > 1: - cmds = [ - # Update min_links - "sonic-db-cli CONFIG_DB hset 'PORTCHANNEL|{}' 'min_links' 2".format(up_portchannel), - # Add back portchannel member - "config portchannel member add {} {}".format(up_portchannel, pc_members[1]), - # Unmask the service - "systemctl unmask teamd", - # Resart teamd - "systemctl restart teamd" - ] - rand_selected_dut.shell_cmds(cmds=cmds) - _wait_portchannel_up(rand_selected_dut, up_portchannel) + # Teardown: Restore the original config + config_reload(rand_selected_dut, config_source="running_golden_config", safe_reload=True) @pytest.fixture From 5bdd9bc9e1e83b6f49a7173c82618460e6f362f0 Mon Sep 17 00:00:00 2001 From: Nikolay Mirin Date: Mon, 9 Feb 2026 21:10:40 -0800 Subject: [PATCH 25/43] Tagging DPU steps in the ansible minigraph scenario. (#22142) DPU config can be deployed after the switch configuration. Minigraph deployment command has been extended: Deploy minigraph for both Switch and DPU: ./testbed-cli.sh deploy-mg {{SWITCH}}-{{TOPO}} lab vault -vvvvv Deploy minigraph for Switch only: ./testbed-cli.sh deploy-mg {{SWITCH}}-{{TOPO}} lab vault -vvvvv --skip-tags "dpu_config" Deploy minigraph for DPU only: ./testbed-cli.sh deploy-mg {{SWITCH}}-{{TOPO}} lab vault -vvvvv --tags "dpu_config" Signed-off-by: nmirin --- ansible/config_sonic_basedon_testbed.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/ansible/config_sonic_basedon_testbed.yml b/ansible/config_sonic_basedon_testbed.yml index 876dcd45695..28a2c33c72f 100644 --- a/ansible/config_sonic_basedon_testbed.yml +++ b/ansible/config_sonic_basedon_testbed.yml @@ -40,13 +40,16 @@ set_fact: testbed_file: testbed.yaml when: testbed_file is not defined + tags: always - name: Gathering testbed information test_facts: testbed_name="{{ testbed_name }}" testbed_file="{{ testbed_file }}" delegate_to: localhost + tags: always - fail: msg="The DUT you are trying to run test does not belongs to this testbed" when: inventory_hostname not in testbed_facts['duts'] + tags: always - name: Set default num_asic set_fact: @@ -60,11 +63,13 @@ - name: set testbed_type set_fact: topo: "{{ testbed_facts['topo'] }}" + tags: always - name: set default light mode set_fact: is_light_mode: true when: topo in ["t1-smartswitch-ha","t1-28-lag","smartswitch-t1", "t1-48-lag"] and is_light_mode is not defined + tags: always - name: set ptf image name set_fact: @@ -915,6 +920,7 @@ become: true # t1-28-lag is smartswitch topo only when: topo in ["t1-smartswitch-ha","t1-28-lag","smartswitch-t1", "t1-48-lag"] and is_light_mode|bool == true + tags: [ dpu_config ] - name: Load DPU config in smartswitch load_extra_dpu_config: @@ -924,6 +930,7 @@ become: true # t1-28-lag is smartswitch topo only when: topo in ["t1-smartswitch-ha","t1-28-lag","smartswitch-t1", "t1-48-lag"] and is_light_mode|bool == true + tags: [ dpu_config ] - name: Configure TACACS become: true From a3e981cabf00b486485d7c6e734548c0ccbf1231 Mon Sep 17 00:00:00 2001 From: Longxiang Date: Thu, 8 Jan 2026 06:11:28 +0000 Subject: [PATCH 26/43] Add parallel manager implementation Signed-off-by: Longxiang --- .../plugins/parallel_fixture/__init__.py | 654 ++++++++++++++++++ 1 file changed, 654 insertions(+) create mode 100644 tests/common/plugins/parallel_fixture/__init__.py diff --git a/tests/common/plugins/parallel_fixture/__init__.py b/tests/common/plugins/parallel_fixture/__init__.py new file mode 100644 index 00000000000..79196c8019b --- /dev/null +++ b/tests/common/plugins/parallel_fixture/__init__.py @@ -0,0 +1,654 @@ +import bisect +import contextlib +import ctypes +import enum +import functools +import logging +import pytest +import threading +import time +import traceback +import sys + +from concurrent.futures import CancelledError +from concurrent.futures import FIRST_EXCEPTION +from concurrent.futures import ALL_COMPLETED +from concurrent.futures import Future +from concurrent.futures import ThreadPoolExecutor +from concurrent.futures import TimeoutError +from concurrent.futures import wait + + +class TaskScope(enum.Enum): + """Defines the lifecycle scopes for parallel task.""" + SESSION = 0 + MODULE = 1 + CLASS = 2 + FUNCTION = 3 + + +class ParallelTaskRuntimeError(BaseException): + pass + + +class ParallelTaskTerminatedError(BaseException): + pass + + +def raise_async_exception(tid, exc_type): + """Injects an exception into the specified thread.""" + if not isinstance(tid, int): + raise TypeError("Thread ID must be an integer") + + ctypes.pythonapi.PyThreadState_SetAsyncExc(ctypes.c_long(tid), + ctypes.py_object(exc_type)) + + +_log_context = threading.local() +_original_log_factory = logging.getLogRecordFactory() + + +def _prefixed_log_factory(*args, **kwargs): + record = _original_log_factory(*args, **kwargs) + # Check if we are inside a parallel task wrapper + prefix = getattr(_log_context, "prefix", None) + if prefix: + # Prepend the prefix to the log message + # This handles standard logging.info("msg") calls + record.msg = f"{prefix} {record.msg}" + return record + + +# Apply the factory globally +logging.setLogRecordFactory(_prefixed_log_factory) + + +class ParallelFixtureManager(object): + + DEFAULT_WAIT_TIMEOUT = 180 + THREAD_POOL_POLLING_INTERVAL = 0.1 + + TASK_SCOPE_SESSION = TaskScope.SESSION + TASK_SCOPE_MODULE = TaskScope.MODULE + TASK_SCOPE_CLASS = TaskScope.CLASS + TASK_SCOPE_FUNCTION = TaskScope.FUNCTION + + class ParallelTaskFuture(Future): + """A Future subclass that supports timeout handling with thread interruption.""" + + @property + def default_result(self): + if hasattr(self, '_default_result'): + return self._default_result + return None + + @default_result.setter + def default_result(self, value): + self._default_result = value + + @property + def timeout(self): + if hasattr(self, '_timeout'): + return self._timeout + return None + + @timeout.setter + def timeout(self, value): + self._timeout = value + + def result(self, timeout=None, interrupt_when_timeout=False, + return_default_on_timeout=False): + try: + return super().result(timeout=timeout) + except TimeoutError: + task_name = self.task_name + if self.cancel(): + logging.warning("[Parallel Fixture] Task %s timed out and was cancelled.", task_name) + elif self.running() and interrupt_when_timeout: + task_context = getattr(self, 'task_context', None) + if task_context and hasattr(task_context, 'tid'): + tid = task_context.tid + if tid: + logging.warning( + "[Parallel Fixture] Task %s timed out. Interrupting thread %s.", + task_name, tid + ) + raise_async_exception(tid, ParallelTaskTerminatedError) + else: + logging.warning("[Parallel Fixture] Task %s timed out but TID not found.", task_name) + if return_default_on_timeout: + logging.info("[Parallel Fixture] Task %s returning default result on timeout: %s", + task_name, self.default_result) + return self.default_result + raise + + class ParallelTaskContext(object): + """Context information for a parallel task.""" + def __init__(self, tid=None, start_time=None, end_time=None, task_name=None): + self.tid = tid + self.start_time = start_time + self.end_time = end_time + self.task_name = task_name + + def __init__(self, worker_count): + self.terminated = False + self.worker_count = worker_count + self.executor = ThreadPoolExecutor(max_workers=worker_count) + + # Initialize buckets for all defined scopes + self.setup_futures = {scope: [] for scope in TaskScope} + self.teardown_futures = {scope: [] for scope in TaskScope} + self.current_scope = None + + # Start the background monitor thread + self.monitor_lock = threading.Lock() + self.active_futures = set() + self.done_futures = set() + self.is_monitor_running = True + self.monitor_thread = threading.Thread(target=self._monitor_workers, daemon=True) + self.monitor_thread.start() + + def _monitor_workers(self): + """Monitor thread pool tasks.""" + i = 0 + while True: + future_threads = {} + with self.monitor_lock: + done_futures = set() + for f in self.active_futures: + future_threads[f.task_context.tid] = f + if f.done(): + done_futures.add(f) + if f.exception(): + logging.info("[Parallel Fixture] Detect exception from task %s: %s", + f.task_name, f.exception()) + else: + logging.info("[Parallel Fixture] Detect task %s is done", f.task_name) + self.active_futures -= done_futures + self.done_futures |= done_futures + + if i % 100 == 0: + # Log the running task of each thread pool worker + # every 10 seconds + log_msg = ["[Parallel Fixture] Current worker threads status:"] + current_time = time.time() + if self.executor._threads: + current_threads = list(self.executor._threads) + current_threads.sort(key=lambda t: (len(t.name), t.name)) + for thread in current_threads: + if thread.is_alive(): + if thread.ident in future_threads: + start_time = future_threads[thread.ident].task_context.start_time + log_msg.append(f"Thread {thread.name}: " + f"{future_threads[thread.ident].task_name}, " + f"{current_time - start_time}s") + else: + log_msg.append(f"Thread {thread.name}: idle") + else: + log_msg.append(f"Thread {thread.name}: terminated") + else: + log_msg.append("No alive worker thread found.") + logging.info("\n".join(log_msg)) + + if not self.is_monitor_running: + break + + time.sleep(ParallelFixtureManager.THREAD_POOL_POLLING_INTERVAL) + i += 1 + + def _resolve_scope(self, scope): + """Ensure scope is a TaskScope Enum member.""" + if isinstance(scope, TaskScope): + return scope + try: + return TaskScope(scope) + except ValueError: + raise ValueError(f"Invalid scope '{scope}'. " + f"Must be one of {[e.value for e in TaskScope]}") + + def _cancel_futures(self, futures): + for future in futures: + future.cancel() + + def _wait_for_futures(self, futures, timeout, + wait_strategy=FIRST_EXCEPTION, reraise=True, + raise_timeout_error=True): + if not futures: + return + + # Wait for all futures to complete + done, not_done = wait(futures, timeout=timeout, return_when=wait_strategy) + + # Check for exceptions in completed tasks + for future in done: + if future.exception(): + # If any exception is raised, cancel the rest + self._cancel_futures(not_done) + if reraise: + raise ParallelTaskRuntimeError from future.exception() + + # Wait timeout, cancel the rest + if not_done: + # Attempt cancel to cleanup + self._cancel_futures(not_done) + if raise_timeout_error: + raise TimeoutError( + f"Parallel Tasks Timed Out! " + f"{len(not_done)} tasks failed to complete within {timeout}s: " + f"{[f.task_name for f in not_done]}" + ) + + def _format_task_name(self, func, *args, **kwargs): + task_name = f"{func.__name__}" + if args: + task_name += f"({args}" + if kwargs: + task_name += f", {kwargs}" + if args or kwargs: + task_name += ")" + return task_name + + def _wrap_task(self, func, task_context): + + @functools.wraps(func) + def wrapper(*args, **kwargs): + tid = threading.get_ident() + task_context.tid = tid + task_context.start_time = time.time() + current_thread = threading.current_thread().name + + prefix = f"[Parallel Fixture][{current_thread}][{task_context.task_name}]" + # Set thread-local context for logging module + _log_context.prefix = prefix + try: + return func(*args, **kwargs) + except Exception: + raise sys.exc_info()[0](traceback.format_exc()) + finally: + _log_context.prefix = None + task_context.end_time = time.time() + logging.debug("[Parallel Fixture] Task %s finished in %.2f seconds", + task_context.task_name, task_context.end_time - task_context.start_time) + + return wrapper + + def wait_for_tasks_completion(self, futures, timeout=DEFAULT_WAIT_TIMEOUT, + wait_strategy=ALL_COMPLETED, reraise=True): + """Block until all given tasks are done.""" + logging.debug("[Parallel Fixture] Waiting for tasks to finish, timeout: %s", timeout) + self._wait_for_futures(futures, timeout, wait_strategy, reraise) + + def submit_setup_task(self, scope, func, *args, **kwargs): + """Submit a setup task to the parallel fixture manager.""" + scope = self._resolve_scope(scope) + task_name = self._format_task_name(func, *args, **kwargs) + logging.info("[Parallel Fixture] Submit setup task (%s): %s", scope, task_name) + task_context = ParallelFixtureManager.ParallelTaskContext(task_name=task_name) + wrapped_func = self._wrap_task(func, task_context) + future = self.executor.submit(wrapped_func, *args, **kwargs) + future.__class__ = ParallelFixtureManager.ParallelTaskFuture + future.task_name = task_name + future.task_context = task_context + self.setup_futures[scope].append(future) + with self.monitor_lock: + self.active_futures.add(future) + return future + + def submit_teardown_task(self, scope, func, *args, **kwargs): + """Submit a teardown task to the parallel fixture manager.""" + scope = self._resolve_scope(scope) + task_name = self._format_task_name(func, *args, **kwargs) + logging.info("[Parallel Fixture] Submit teardown task (%s): %s", scope, task_name) + task_context = ParallelFixtureManager.ParallelTaskContext(task_name=task_name) + wrapped_func = self._wrap_task(func, task_context) + future = self.executor.submit(wrapped_func, *args, **kwargs) + future.__class__ = ParallelFixtureManager.ParallelTaskFuture + future.task_name = task_name + future.task_context = task_context + self.teardown_futures[scope].append(future) + with self.monitor_lock: + self.active_futures.add(future) + return future + + def wait_for_setup_tasks(self, scope, + timeout=DEFAULT_WAIT_TIMEOUT, + wait_strategy=FIRST_EXCEPTION, reraise=True): + """Block until all setup tasks in a specific scope are done.""" + logging.debug("[Parallel Fixture] Waiting for setup tasks to finish, scope: %s, timeout: %s", scope, timeout) + scope = self._resolve_scope(scope) + futures = self.setup_futures.get(scope, []) + self._wait_for_futures(futures, timeout, wait_strategy, reraise) + + def wait_for_teardown_tasks(self, scope, + timeout=DEFAULT_WAIT_TIMEOUT, + wait_strategy=FIRST_EXCEPTION, reraise=True): + """Block until all teardown tasks in a specific scope are done.""" + logging.debug("[Parallel Fixture] Waiting for teardown tasks to finish, scope: %s, timeout: %s", scope, timeout) + scope = self._resolve_scope(scope) + futures = self.teardown_futures.get(scope, []) + self._wait_for_futures(futures, timeout, wait_strategy, reraise) + + def terminate(self): + """Terminate the parallel fixture manager.""" + + if self.terminated: + return + + logging.info("[Parallel Fixture] Terminating parallel fixture manager") + + self.terminated = True + + # Stop the monitor + self.is_monitor_running = False + self.monitor_thread.join() + + # Cancel any pending futures + for future in self.active_futures: + future.cancel() + + # Force terminate the thread pool workers that are still running + running_futures = [future for future in self.active_futures if not future.done()] + logging.debug("[Parallel Fixture] Running tasks to be terminated: %s", [_.task_name for _ in running_futures]) + if running_futures: + logging.debug("[Parallel Fixture] Force interrupt thread pool workers") + running_futures_tids = [future.task_context.tid for future in running_futures] + for thread in self.executor._threads: + if thread.is_alive() and thread.ident in running_futures_tids: + raise_async_exception(thread.ident, ParallelTaskTerminatedError) + + logging.debug("[Parallel Fixture] Current worker threads: %s", + [thread.is_alive() for thread in self.executor._threads]) + # Wait for all threads to terminate + self.executor.shutdown(wait=True) + logging.debug("[Parallel Fixture] Current worker threads: %s", + [thread.is_alive() for thread in self.executor._threads]) + + cancel_futures = [] + stopped_futures = [] + pending_futures = [] + done_futures = self.done_futures + for future in self.active_futures: + try: + exc = future.exception(0.1) + if isinstance(exc, ParallelTaskTerminatedError): + stopped_futures.append(future) + except CancelledError: + cancel_futures.append(future) + except TimeoutError: + # NOTE: should never hit this as all futures are either + # cancelled or stopped with ParallelTaskTerminatedError + pending_futures.append(future) + + logging.debug(f"[Parallel Fixture] The fixture manager is terminated:\n" + f"stopped tasks {[_.task_name for _ in stopped_futures]},\n" + f"canceled tasks {[_.task_name for _ in cancel_futures]},\n" + f"pending tasks {[_.task_name for _ in pending_futures]},\n" + f"done tasks {[(_.task_name, _.exception()) for _ in done_futures]}.") + + def reset(self): + """Reset the parallel fixture manager.""" + if not self.terminated: + raise RuntimeError("Cannot reset a running parallel fixture manager.") + + self.active_futures.clear() + self.done_futures.clear() + self.executor = ThreadPoolExecutor(max_workers=self.worker_count) + self.is_monitor_running = True + self.monitor_thread = threading.Thread(target=self._monitor_workers, daemon=True) + self.monitor_thread.start() + + def check_for_exception(self): + """Check done futures and re-raise any exception.""" + with self.monitor_lock: + for future in self.done_futures: + if future.exception(): + raise ParallelTaskRuntimeError from future.exception() + + def is_task_finished(self, future): + return future.done() and future.exception() is None + + def __del__(self): + self.terminate() + + +@contextlib.contextmanager +def log_function_call_duration(func_name): + start = time.time() + logging.debug("[Parallel Fixture] Start %s", func_name) + yield + logging.debug("[Parallel Fixture] End %s, duration %s", func_name, time.time() - start) + + +# ----------------------------------------------------------------- +# the parallel manager fixture +# ----------------------------------------------------------------- + + +_PARALLEL_MANAGER = None + + +@pytest.fixture(scope="session", autouse=True) +def parallel_manager(): + global _PARALLEL_MANAGER + _PARALLEL_MANAGER = ParallelFixtureManager(worker_count=16) + _PARALLEL_MANAGER.current_scope = TaskScope.SESSION + return _PARALLEL_MANAGER + + +# ----------------------------------------------------------------- +# the setup barrier fixtures +# ----------------------------------------------------------------- + + +@pytest.fixture(scope="session", autouse=True) +def setup_barrier_session(parallel_manager): + """Barrier to wait for all session level setup tasks to finish.""" + with log_function_call_duration("setup_barrier_session"): + parallel_manager.wait_for_setup_tasks(TaskScope.SESSION) + parallel_manager.current_scope = TaskScope.MODULE + yield + return + + +@pytest.fixture(scope="module", autouse=True) +def setup_barrier_module(parallel_manager): + """Barrier to wait for all module level setup tasks to finish.""" + with log_function_call_duration("setup_barrier_module"): + parallel_manager.wait_for_setup_tasks(TaskScope.MODULE) + parallel_manager.current_scope = TaskScope.CLASS + yield + return + + +@pytest.fixture(scope="class", autouse=True) +def setup_barrier_class(parallel_manager): + """Barrier to wait for all class level setup tasks to finish.""" + with log_function_call_duration("setup_barrier_class"): + parallel_manager.wait_for_setup_tasks(TaskScope.CLASS) + parallel_manager.current_scope = TaskScope.FUNCTION + yield + return + + +@pytest.fixture(scope="function", autouse=True) +def setup_barrier_function(parallel_manager): + """Barrier to wait for all function level setup tasks to finish.""" + with log_function_call_duration("setup_barrier_function"): + parallel_manager.wait_for_setup_tasks(TaskScope.FUNCTION) + parallel_manager.current_scope = None + yield + return + + +# ----------------------------------------------------------------- +# the teardown barrier fixtures +# ----------------------------------------------------------------- + + +@pytest.fixture(scope="session", autouse=True) +def teardown_barrier_session(parallel_manager): + """Barrier to wait for all session level teardown tasks to finish.""" + yield + with log_function_call_duration("teardown_barrier_session"): + parallel_manager.wait_for_teardown_tasks(TaskScope.SESSION) + parallel_manager.current_scope = None + + +@pytest.fixture(scope="module", autouse=True) +def teardown_barrier_module(parallel_manager): + """Barrier to wait for all module level teardown tasks to finish.""" + yield + with log_function_call_duration("teardown_barrier_module"): + parallel_manager.wait_for_teardown_tasks(TaskScope.MODULE) + parallel_manager.current_scope = TaskScope.SESSION + + +@pytest.fixture(scope="class", autouse=True) +def teardown_barrier_class(parallel_manager): + """Barrier to wait for all class level teardown tasks to finish.""" + yield + with log_function_call_duration("teardown_barrier_class"): + parallel_manager.wait_for_teardown_tasks(TaskScope.CLASS) + parallel_manager.current_scope = TaskScope.MODULE + + +@pytest.fixture(scope="function", autouse=True) +def teardown_barrier_function(parallel_manager): + """Barrier to wait for all function level teardown tasks to finish.""" + yield + with log_function_call_duration("teardown_barrier_function"): + parallel_manager.wait_for_teardown_tasks(TaskScope.FUNCTION) + parallel_manager.current_scope = TaskScope.CLASS + + +# ----------------------------------------------------------------- +# pytest hooks +# ----------------------------------------------------------------- + + +@pytest.hookimpl(wrapper=True) +def pytest_runtest_setup(item): + """ + HOOK: Runs once BEFORE every fixture setup. + Reorder the setup/teardown barriers to ensure barriers should run + after ALL fixtures of the same-scope. + """ + logging.debug("[Parallel Fixture] Setup barrier fixtures") + + barriers = { + TaskScope.SESSION.value: ["teardown_barrier_session", + "setup_barrier_session"], + TaskScope.MODULE.value: ["teardown_barrier_module", + "setup_barrier_module"], + TaskScope.CLASS.value: ["teardown_barrier_class", + "setup_barrier_class"], + TaskScope.FUNCTION.value: ["teardown_barrier_function", + "setup_barrier_function"] + } + fixtureinfo = item._fixtureinfo + current_fixture_names = fixtureinfo.names_closure[:] + + logging.debug("[Parallel Fixture] Fixture order before:\n%s", current_fixture_names) + + for fixtures in barriers.values(): + for fixture in fixtures: + current_fixture_names.remove(fixture) + current_fixture_scopes = [] + for fixture in current_fixture_names: + fixture_defs = fixtureinfo.name2fixturedefs.get(fixture, []) + if not fixture_defs: + fixture_scope = current_fixture_scopes[-1] + else: + fixture_scope = TaskScope[fixture_defs[0].scope.upper()].value + current_fixture_scopes.append(fixture_scope) + + # NOTE: Inject the barriers to ensure they are running last + # in the fixtures of the same scope. + for scope, fixtures in barriers.items(): + for fixture in fixtures: + if fixture.startswith("setup"): + insert_pos = bisect.bisect_right(current_fixture_scopes, scope) + current_fixture_names.insert(insert_pos, fixture) + current_fixture_scopes.insert(insert_pos, scope) + if fixture.startswith("teardown"): + insert_pos = bisect.bisect_left(current_fixture_scopes, scope) + current_fixture_names.insert(insert_pos, fixture) + current_fixture_scopes.insert(insert_pos, scope) + + logging.debug("[Parallel Fixture] Fixture order after:\n%s", current_fixture_names) + fixtureinfo.names_closure[:] = current_fixture_names + + yield + return + + +@pytest.hookimpl(tryfirst=True) +def pytest_fixture_setup(fixturedef, request): + """ + HOOK: Runs BEFORE every fixture setup. + If a background task failed while the PREVIOUS fixture was running, + we catch it here and stop the next fixture from starting. + """ + if _PARALLEL_MANAGER: + logging.debug("[Parallel Fixture] Check for fixture exceptions before running %r", fixturedef) + _PARALLEL_MANAGER.check_for_exception() + + +@pytest.hookimpl(tryfirst=True) +def pytest_runtest_call(item): + """ + HOOK: Runs BEFORE the test function starts. + Happy path to terminate the parallel fixture manager. + All tasks should be done as those barrier fixtures should catch them + all. + """ + logging.debug("[Parallel Fixture] Wait for tasks to finish before test function") + parallel_manager = _PARALLEL_MANAGER + if parallel_manager: + try: + for scope in TaskScope: + parallel_manager.wait_for_setup_tasks(scope) + finally: + parallel_manager.terminate() + + +def pytest_exception_interact(call, report): + """ + HOOK: Runs WHEN an exception occurs. + Sad path to terminate the parallel fixture manager. + When a ParallelTaskRuntimeError is detected, tries to poll + the rest running tasks and terminate the parallel manager. + """ + parallel_manager = _PARALLEL_MANAGER + if report.when == "setup": + reraise = not isinstance(call.excinfo.value, ParallelTaskRuntimeError) + logging.debug("[Parallel Fixture] Wait for tasks to finish after exception occurred in setup %s", + call.excinfo.value) + try: + for scope in TaskScope: + parallel_manager.wait_for_setup_tasks(scope, wait_strategy=ALL_COMPLETED, reraise=reraise) + finally: + parallel_manager.terminate() + + +def pytest_runtest_teardown(item, nextitem): + """ + HOOK: Runs once BEFORE all fixture teardown. + Reset the parallel manager. + """ + logging.debug("[Parallel Fixture] Reset parallel manager before teardown") + parallel_manager = _PARALLEL_MANAGER + if parallel_manager: + parallel_manager.reset() + parallel_manager.current_scope = TaskScope.FUNCTION + + +def pytest_runtest_logreport(report): + """ + HOOK: Runs once AFTER all fixture teardown. + Terminate the parallel manager. + """ + logging.debug("[Parallel Fixture] Terminate parallel manager after teardown") + parallel_manager = _PARALLEL_MANAGER + if parallel_manager: + parallel_manager.terminate() From 689a5b4afedf37e422722d7e1d18cd41bdf02eb3 Mon Sep 17 00:00:00 2001 From: Longxiang Date: Thu, 8 Jan 2026 06:17:33 +0000 Subject: [PATCH 27/43] Add HLD Signed-off-by: Longxiang --- .../common/plugins/parallel_fixture/README.md | 116 ++++++++++++++++++ .../parallel_fixture/images/pytest.jpg | Bin 0 -> 259519 bytes 2 files changed, 116 insertions(+) create mode 100644 tests/common/plugins/parallel_fixture/README.md create mode 100644 tests/common/plugins/parallel_fixture/images/pytest.jpg diff --git a/tests/common/plugins/parallel_fixture/README.md b/tests/common/plugins/parallel_fixture/README.md new file mode 100644 index 00000000000..e36e6281694 --- /dev/null +++ b/tests/common/plugins/parallel_fixture/README.md @@ -0,0 +1,116 @@ +# Parallel Fixture Manager Design Document + +## 1. Overview + +The **Parallel Fixture Manager** is a pytest plugin designed to optimize test execution time by parallelizing the setup and teardown of fixtures. The sonic-mgmt fixture setup/teardowns often involves blocking I/O operations such as device configuration, service restarts, or waiting for convergence. By offloading these tasks to a thread pool, the manager allows multiple same-level fixtures to setup/teardown concurrently, which could reduce the overall test execution time. + +![test execution](images/pytest.jpg) + +## 2. Requirements + +The Parallel Fixture Manager is designed to address specific challenges in the SONiC testing infrastructure. The key requirements are: + +* **Test Fixture Setup/Teardown Parallelization** +* **Scope-Based Synchronization** + * The system must strictly enforce pytest scoping rules: + 1. All background tasks associated with a specific scope (Session, Module, Class, Function) in setup must complete successfully before the test runner proceeds to a narrower scope or executes the test function. + 2. All background tasks associated with a specific scope (Session, Module, Class, Function) in teardown must complete successfully before the test runner proceeds to a broader scope or finish the test execution. +* **Fail-Fast Reliability** + * The system must immediately detect the exception in the background thread and abort the ongoing test setup to prevent cascading failures, resource wastage, and misleading test results. + * The system must immediately detect the exception and abort the ongoing test setup to prevent cascading failures, resource wastage, and misleading test results. +* **Non-Intrusive Integration** + * The system must expose a minimal and intuitive API. Existing fixtures should be able to adopt parallel execution patterns with minimal code changes, preserving the standard pytest fixture structure. +* **Safe Termination & Cleanup** + * The system must handle interruptions and timeouts gracefully. It must ensure that background threads are properly terminated and resources are cleaned up, even in the event of a test failure or user interruption. + +## 3. Architecture + +### 3.1 Core Components + +* **`ParallelFixtureManager`**: The central thread pool controller exposed as a session-scoped fixture (`parallel_manager`). + * **Executor**: Uses `concurrent.futures.ThreadPoolExecutor` to execute tasks. + * **Monitor Thread**: A daemon thread (`_monitor_workers`) that polls active futures to log task execution status and any exception in worker thread. + * **Task Queues**: Maintains separate lists of futures for setup and teardown tasks, categorized by scope. +* **`TaskScope`**: Enum defining the lifecycle scopes: `SESSION`, `MODULE`, `CLASS`, and `FUNCTION`. +* **`Barriers`**: Autouse fixtures that enforce synchronization. They block the main thread until all background tasks for a specific scope are complete. + * Setup Barriers: + * `setup_barrier_session` + * `setup_barrier_module` + * `setup_barrier_class` + * `setup_barrier_function` + * Teardown Barriers: + * `teardown_barrier_session` + * `teardown_barrier_module` + * `teardown_barrier_class` + * `teardown_barrier_function` + +### 3.2 Execution Lifecycle + +The manager hooks into the pytest lifecycle to coordinate parallel execution: + +#### Setup Phase + +1. **Submission**: Fixtures submit setup functions using `parallel_manager.submit_setup_task(scope, func, *args, **kwargs)`. +2. **Non-Blocking Return/Yield**: The fixture yields/Returns immediately, allowing pytest to proceed to the next fixture. +3. **Barrier Enforcement**: At the end of a scope (e.g., after all module-scoped fixtures have run), a barrier fixture waits for all submitted tasks of that scope to complete. + +#### Test Execution Phase + +1. **Pre-Test Wait**: Before the test function runs, the manager ensures all setup tasks are finished. +2. **Termination**: The setup executor is terminated to ensure a stable environment during the test. + +#### Teardown Phase + +1. **Restart**: The manager is restarted to handle teardown tasks. +2. **Submission**: Fixtures submit teardown functions using `parallel_manager.submit_teardown_task(scope, func, *args, **kwargs)`. +3. **Barrier Enforcement**: Teardown barriers wait for tasks to complete before moving to the next scope. + +## 4. Exception Handling Strategy + +The system implements a **Fail-Fast** strategy to detect exceptions in the background threads and fail the main Pytest thread timely, which helps prevent cascading failures and wasted execution time. + +* **Background Exception Logging**: The monitor thread detects and logs exceptions in worker threads as they happen. +* **Checkpoints**: + * **`pytest_fixture_setup`**: Before starting *any* fixture, the manager checks if a background task has failed. If so, it raises `ParallelTaskRuntimeError` immediately, aborting the test setup immediately. + * **Barriers**: When waiting at a barrier, exceptions from failed tasks are re-raised in the main thread. +* **Forced Termination**: In cases of interrupts or critical failures, `ctypes` is used to inject exceptions into worker threads to force immediate termination. + +## 5. Pytest Hooks Integration + +The plugin relies on several pytest hooks to orchestrate the flow: + +* **`pytest_runtest_setup`**: Dynamically reorders fixtures to ensure that barrier fixtures always execute **after** all other fixtures of the same scope. +* **`pytest_fixture_setup`**: Performs as exception handling checkpoint to interrupt the test execution in case of any exceptions are detected in the background threads. +* **`pytest_runtest_call`**: Acts as a final gate before the test runs, ensuring all setup tasks are done and terminating the setup executor. +* **`pytest_exception_interact`**: Handles exceptions during setup/call to terminate the manager gracefully. +* **`pytest_runtest_teardown`**: Restarts the parallel manager to prepare for the teardown phase. +* **`pytest_runtest_logreport`**: Terminates the parallel manager gracefully after teardown is complete. + +## 6. Usage Example + +Fixtures interact with the parallel manager via the `parallel_manager` fixture. + +```python +import pytest +from tests.common.plugins.parallel_fixture import TaskScope + +@pytest.fixture(scope="module") +def heavy_initialization(parallel_manager): + def setup_logic(): + # Configure DUT + ... + + def teardown_logic(): + # Cleanup resources + ... + + # Submit setup task to run in background + future = parallel_manager.submit_setup_task(TaskScope.MODULE, setup_logic) + + # Yield immediately to let other fixtures start + yield + + # Submit teardown only if setup completed successfully + if parallel_manager.is_task_finished(future): + parallel_manager.submit_teardown_task(TaskScope.MODULE, teardown_logic) +``` diff --git a/tests/common/plugins/parallel_fixture/images/pytest.jpg b/tests/common/plugins/parallel_fixture/images/pytest.jpg new file mode 100644 index 0000000000000000000000000000000000000000..f066d793e6d9f3e0f7f07d1f23a9df1345aed983 GIT binary patch literal 259519 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 literal 0 HcmV?d00001 From f3311d23d5e4e5b405e7911d71813fdea9a7c410 Mon Sep 17 00:00:00 2001 From: Longxiang Date: Thu, 8 Jan 2026 07:47:50 +0000 Subject: [PATCH 28/43] Make SONiC device classes thread-safe Signed-off-by: Longxiang --- tests/common/devices/base.py | 29 ++++++++++++++--------------- tests/common/devices/duthosts.py | 15 +++++++++++---- tests/common/devices/multi_asic.py | 16 +++++++++------- 3 files changed, 34 insertions(+), 26 deletions(-) diff --git a/tests/common/devices/base.py b/tests/common/devices/base.py index 9db40b94d7a..87f2bd01a9c 100644 --- a/tests/common/devices/base.py +++ b/tests/common/devices/base.py @@ -55,21 +55,20 @@ def __init__(self, ansible_adhoc, hostname, *args, **kwargs): def __getattr__(self, module_name): if self.host.has_module(module_name): - self.module_name = module_name - self.module = getattr(self.host, module_name) - - return self._run + def _run_wrapper(*module_args, **kwargs): + return self._run(module_name, *module_args, **kwargs) + return _run_wrapper raise AttributeError( "'%s' object has no attribute '%s'" % (self.__class__, module_name) ) - def _run(self, *module_args, **complex_args): + def _run(self, module_name, *module_args, **complex_args): previous_frame = inspect.currentframe().f_back filename, line_number, function_name, lines, index = inspect.getframeinfo(previous_frame) verbose = complex_args.pop('verbose', True) - + module = getattr(self.host, module_name) if verbose: logger.debug( "{}::{}#{}: [{}] AnsibleModule::{}, args={}, kwargs={}".format( @@ -77,7 +76,7 @@ def _run(self, *module_args, **complex_args): function_name, line_number, self.hostname, - self.module_name, + module_name, json.dumps(module_args, cls=AnsibleHostBase.CustomEncoder), json.dumps(complex_args, cls=AnsibleHostBase.CustomEncoder) ) @@ -89,7 +88,7 @@ def _run(self, *module_args, **complex_args): function_name, line_number, self.hostname, - self.module_name + module_name ) ) @@ -98,7 +97,7 @@ def _run(self, *module_args, **complex_args): if module_async: def run_module(module_args, complex_args): - return self.module(*module_args, **complex_args)[self.hostname] + return module(*module_args, **complex_args)[self.hostname] pool = ThreadPool() result = pool.apply_async(run_module, (module_args, complex_args)) return pool, result @@ -106,9 +105,9 @@ def run_module(module_args, complex_args): module_args = json.loads(json.dumps(module_args, cls=AnsibleHostBase.CustomEncoder)) complex_args = json.loads(json.dumps(complex_args, cls=AnsibleHostBase.CustomEncoder)) - adhoc_res: AdHocResult = self.module(*module_args, **complex_args) + adhoc_res: AdHocResult = module(*module_args, **complex_args) - if self.module_name == "meta": + if module_name == "meta": # The meta module is special in Ansible - it doesn't execute on remote hosts, it controls Ansible's behavior # There are no per-host ModuleResults contained within it return @@ -123,7 +122,7 @@ def run_module(module_args, complex_args): function_name, line_number, self.hostname, - self.module_name, json.dumps(hostname_res, cls=AnsibleHostBase.CustomEncoder) + module_name, json.dumps(hostname_res, cls=AnsibleHostBase.CustomEncoder) ) ) else: @@ -133,14 +132,14 @@ def run_module(module_args, complex_args): function_name, line_number, self.hostname, - self.module_name, + module_name, hostname_res.is_failed, hostname_res.get('rc', None) ) ) if (hostname_res.is_failed or 'exception' in hostname_res) and not module_ignore_errors: - raise RunAnsibleModuleFail("run module {} failed".format(self.module_name), hostname_res) + raise RunAnsibleModuleFail("run module {} failed".format(module_name), hostname_res) return hostname_res @@ -150,4 +149,4 @@ def __str__(self): return str(self["host"]) def __repr__(self): - return self.__str__() + return self.__str__() \ No newline at end of file diff --git a/tests/common/devices/duthosts.py b/tests/common/devices/duthosts.py index a1165950dd1..b4a7ff03abf 100644 --- a/tests/common/devices/duthosts.py +++ b/tests/common/devices/duthosts.py @@ -19,9 +19,9 @@ class DutHosts(object): """ class _Nodes(list): """ Internal class representing a list of MultiAsicSonicHosts """ - def _run_on_nodes(self, *module_args, **complex_args): + def _run_on_nodes(self, module, *module_args, **complex_args): """ Delegate the call to each of the nodes, return the results in a dict.""" - return {node.hostname: getattr(node, self.attr)(*module_args, **complex_args) for node in self} + return {node.hostname: getattr(node, module)(*module_args, **complex_args) for node in self} def __getattr__(self, attr): """ To support calling ansible modules on a list of MultiAsicSonicHost @@ -32,8 +32,9 @@ def __getattr__(self, attr): a dictionary with key being the MultiAsicSonicHost's hostname, and value being the output of ansible module on that MultiAsicSonicHost """ - self.attr = attr - return self._run_on_nodes + def _run_on_nodes_wrapper(*module_args, **complex_args): + return self._run_on_nodes(attr, *module_args, **complex_args) + return _run_on_nodes_wrapper def __eq__(self, o): """ To support eq operator on the DUTs (nodes) in the testbed """ @@ -62,6 +63,12 @@ def __init__(self, ansible_adhoc, tbinfo, request, duts, target_hostname=None, i self.request = request self.duts = duts self.is_parallel_run = target_hostname is not None + # Initialize _nodes to None to avoid recursion in __getattr__ + self._nodes = None + self._nodes_for_parallel = None + self._supervisor_nodes = None + self._frontend_nodes = None + # TODO: Initialize the nodes in parallel using multi-threads? if self.is_parallel_run: self.parallel_run_stage = NON_INITIAL_CHECKS_STAGE diff --git a/tests/common/devices/multi_asic.py b/tests/common/devices/multi_asic.py index 9350f68640a..a72100086f3 100644 --- a/tests/common/devices/multi_asic.py +++ b/tests/common/devices/multi_asic.py @@ -124,10 +124,11 @@ def critical_services_tracking_list(self): def get_default_critical_services_list(self): return self._DEFAULT_SERVICES - def _run_on_asics(self, *module_args, **complex_args): + def _run_on_asics(self, multi_asic_attr, *module_args, **complex_args): """ Run an asible module on asics based on 'asic_index' keyword in complex_args Args: + multi_asic_attr: name of the ansible module to run module_args: other ansible module args passed from the caller complex_args: other ansible keyword args @@ -148,7 +149,7 @@ def _run_on_asics(self, *module_args, **complex_args): """ if "asic_index" not in complex_args: # Default ASIC/namespace - return getattr(self.sonichost, self.multi_asic_attr)(*module_args, **complex_args) + return getattr(self.sonichost, multi_asic_attr)(*module_args, **complex_args) else: asic_complex_args = copy.deepcopy(complex_args) asic_index = asic_complex_args.pop("asic_index") @@ -157,11 +158,11 @@ def _run_on_asics(self, *module_args, **complex_args): if self.sonichost.facts['num_asic'] == 1: if asic_index != 0: raise ValueError("Trying to run module '{}' against asic_index '{}' on a single asic dut '{}'" - .format(self.multi_asic_attr, asic_index, self.sonichost.hostname)) - return getattr(self.asic_instance(asic_index), self.multi_asic_attr)(*module_args, **asic_complex_args) + .format(multi_asic_attr, asic_index, self.sonichost.hostname)) + return getattr(self.asic_instance(asic_index), multi_asic_attr)(*module_args, **asic_complex_args) elif type(asic_index) == str and asic_index.lower() == "all": # All ASICs/namespace - return [getattr(asic, self.multi_asic_attr)(*module_args, **asic_complex_args) for asic in self.asics] + return [getattr(asic, multi_asic_attr)(*module_args, **asic_complex_args) for asic in self.asics] else: raise ValueError("Argument 'asic_index' must be an int or string 'all'.") @@ -358,8 +359,9 @@ def __getattr__(self, attr): """ sonic_asic_attr = getattr(SonicAsic, attr, None) if not attr.startswith("_") and sonic_asic_attr and callable(sonic_asic_attr): - self.multi_asic_attr = attr - return self._run_on_asics + def _run_on_asics_wrapper(*module_args, **complex_args): + return self._run_on_asics(attr, *module_args, **complex_args) + return _run_on_asics_wrapper else: return getattr(self.sonichost, attr) # For backward compatibility From 7fb68dedf10fbb1237148faebecaf31fccf67f8b Mon Sep 17 00:00:00 2001 From: Longxiang Date: Thu, 8 Jan 2026 07:55:19 +0000 Subject: [PATCH 29/43] Fix deadlock in child process after fork Signed-off-by: Longxiang --- ansible/ansible.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ansible/ansible.cfg b/ansible/ansible.cfg index af6339f7b7a..5c71740bc39 100644 --- a/ansible/ansible.cfg +++ b/ansible/ansible.cfg @@ -124,7 +124,7 @@ connection_plugins = plugins/connection lookup_plugins = plugins/lookup # vars_plugins = /usr/share/ansible_plugins/vars_plugins filter_plugins = plugins/filter -callback_whitelist = profile_tasks +# callback_whitelist = profile_tasks # by default callbacks are not loaded for /bin/ansible, enable this if you # want, for example, a notification or logging callback to also apply to From 147717940ff6b98be2ffc157bb527a46c04e3b8a Mon Sep 17 00:00:00 2001 From: Longxiang Date: Thu, 8 Jan 2026 07:55:36 +0000 Subject: [PATCH 30/43] Hook parallel manager Signed-off-by: Longxiang --- tests/conftest.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 0963f2ae611..724ece0e258 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -115,7 +115,8 @@ 'tests.common.plugins.conditional_mark', 'tests.common.plugins.random_seed', 'tests.common.plugins.memory_utilization', - 'tests.common.fixtures.duthost_utils') + 'tests.common.fixtures.duthost_utils', + 'tests.common.plugins.parallel_fixture') patch_ansible_worker_process() From 7422a341cbf1a2aea76d8f009aa5db6e3f57915f Mon Sep 17 00:00:00 2001 From: Longxiang Date: Thu, 8 Jan 2026 11:58:38 +0000 Subject: [PATCH 31/43] Fix exception interact Signed-off-by: Longxiang --- tests/common/plugins/parallel_fixture/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/common/plugins/parallel_fixture/__init__.py b/tests/common/plugins/parallel_fixture/__init__.py index 79196c8019b..b2ea5f17869 100644 --- a/tests/common/plugins/parallel_fixture/__init__.py +++ b/tests/common/plugins/parallel_fixture/__init__.py @@ -620,7 +620,7 @@ def pytest_exception_interact(call, report): the rest running tasks and terminate the parallel manager. """ parallel_manager = _PARALLEL_MANAGER - if report.when == "setup": + if parallel_manager and report.when == "setup": reraise = not isinstance(call.excinfo.value, ParallelTaskRuntimeError) logging.debug("[Parallel Fixture] Wait for tasks to finish after exception occurred in setup %s", call.excinfo.value) From f463a46d3b0da92d9089bcfde642e2788f484f6a Mon Sep 17 00:00:00 2001 From: Longxiang Date: Thu, 8 Jan 2026 13:13:37 +0000 Subject: [PATCH 32/43] Refine fixture reorder logic Signed-off-by: Longxiang --- tests/common/plugins/parallel_fixture/__init__.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/common/plugins/parallel_fixture/__init__.py b/tests/common/plugins/parallel_fixture/__init__.py index b2ea5f17869..bd6bf6979ce 100644 --- a/tests/common/plugins/parallel_fixture/__init__.py +++ b/tests/common/plugins/parallel_fixture/__init__.py @@ -559,7 +559,12 @@ def pytest_runtest_setup(item): if not fixture_defs: fixture_scope = current_fixture_scopes[-1] else: - fixture_scope = TaskScope[fixture_defs[0].scope.upper()].value + try: + fixture_scope = TaskScope[fixture_defs[0].scope.upper()].value + except Exception: + logging.debug("[Parallel Fixture] Unknown fixture scope for %r," + "default to previous scope", fixture_defs) + fixture_scope = current_fixture_scopes[-1] current_fixture_scopes.append(fixture_scope) # NOTE: Inject the barriers to ensure they are running last @@ -640,7 +645,7 @@ def pytest_runtest_teardown(item, nextitem): parallel_manager = _PARALLEL_MANAGER if parallel_manager: parallel_manager.reset() - parallel_manager.current_scope = TaskScope.FUNCTION + parallel_manager.current_scope = TaskScope.FUNCTION def pytest_runtest_logreport(report): From b428de2736327fa08bf75a5f066124213b9544f9 Mon Sep 17 00:00:00 2001 From: Longxiang Lyu Date: Wed, 14 Jan 2026 04:38:52 +0000 Subject: [PATCH 33/43] Enable docker sys_ptrace Signed-off-by: Longxiang Lyu --- setup-container.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup-container.sh b/setup-container.sh index 905132f5954..d95cd7bb328 100755 --- a/setup-container.sh +++ b/setup-container.sh @@ -369,7 +369,7 @@ function start_local_container() { docker start ${CONTAINER_NAME} else log_info "creating a container: ${CONTAINER_NAME} ..." - eval "docker run -d -t ${PUBLISH_PORTS} ${ENV_VARS} -h ${CONTAINER_NAME} \ + eval "docker run --cap-add=SYS_PTRACE -d -t ${PUBLISH_PORTS} ${ENV_VARS} -h ${CONTAINER_NAME} \ -v \"$(dirname "${SCRIPT_DIR}"):${LINK_DIR}:rslave\" ${MOUNT_POINTS} \ --name \"${CONTAINER_NAME}\" \"${LOCAL_IMAGE}\" /bin/bash ${SILENT_HOOK}" || \ exit_failure "failed to start a container: ${CONTAINER_NAME}" From 98c2d57b15992d4a3b77e8b2bf94118c61a34a65 Mon Sep 17 00:00:00 2001 From: Longxiang Lyu Date: Thu, 15 Jan 2026 12:06:31 +0000 Subject: [PATCH 34/43] Get thread worker count based on DUT count Signed-off-by: Longxiang Lyu --- tests/common/plugins/parallel_fixture/__init__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/common/plugins/parallel_fixture/__init__.py b/tests/common/plugins/parallel_fixture/__init__.py index bd6bf6979ce..241c4c10fd8 100644 --- a/tests/common/plugins/parallel_fixture/__init__.py +++ b/tests/common/plugins/parallel_fixture/__init__.py @@ -428,9 +428,11 @@ def log_function_call_duration(func_name): @pytest.fixture(scope="session", autouse=True) -def parallel_manager(): +def parallel_manager(tbinfo): + dut_count = len(tbinfo.get("duts", [])) + worker_count = max(dut_count * 8, 16) global _PARALLEL_MANAGER - _PARALLEL_MANAGER = ParallelFixtureManager(worker_count=16) + _PARALLEL_MANAGER = ParallelFixtureManager(worker_count=worker_count) _PARALLEL_MANAGER.current_scope = TaskScope.SESSION return _PARALLEL_MANAGER From a9e5e2412cf52f89af86078b6ee7c0232100df6b Mon Sep 17 00:00:00 2001 From: Longxiang Lyu Date: Thu, 15 Jan 2026 12:07:12 +0000 Subject: [PATCH 35/43] Fix ansible display deadlock Signed-off-by: Longxiang Lyu --- tests/conftest.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index 724ece0e258..1be6dc19c03 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -118,6 +118,8 @@ 'tests.common.fixtures.duthost_utils', 'tests.common.plugins.parallel_fixture') +patch_ansible_worker_process() + patch_ansible_worker_process() fix_logging_handler_fork_lock() From 26a33b40e640dedb6320fdf2ce64b3e327f7eddd Mon Sep 17 00:00:00 2001 From: Longxiang Lyu Date: Thu, 15 Jan 2026 12:17:37 +0000 Subject: [PATCH 36/43] Add deadlock handling to the doc Signed-off-by: Longxiang Lyu --- .../common/plugins/parallel_fixture/README.md | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/tests/common/plugins/parallel_fixture/README.md b/tests/common/plugins/parallel_fixture/README.md index e36e6281694..30b340a2c48 100644 --- a/tests/common/plugins/parallel_fixture/README.md +++ b/tests/common/plugins/parallel_fixture/README.md @@ -86,7 +86,25 @@ The plugin relies on several pytest hooks to orchestrate the flow: * **`pytest_runtest_teardown`**: Restarts the parallel manager to prepare for the teardown phase. * **`pytest_runtest_logreport`**: Terminates the parallel manager gracefully after teardown is complete. -## 6. Usage Example +## 6. Deadlock Handling + +The Parallel Fixture Manager introduces multi-threading to the test execution environment. When combined with multi-processing (e.g., Ansible execution, `multiprocessing.Process`), this creates a risk of deadlocks, particularly involving logging locks. + +A common scenario is: +1. Thread A (main thread) calls a logging function and acquires the logging lock. +2. Thread B (parallel fixture manager worker) forks a new process (e.g., to run an Ansible task). +3. The child process inherits the memory state, including the held logging lock. +4. Since Thread A does not exist in the child process, the lock remains held indefinitely. +5. If the child process tries to log something, it attempts to acquire the lock and deadlocks. + +To prevent this, the framework (in `tests/common/helpers/parallel.py`) leverages `os.register_at_fork` hooks to: +* Acquire logging locks before forking. +* Release logging locks after forking (in both parent and child). +* Handle Ansible display locks similarly. + +This ensures that **locks are always in a released state within the child process immediately after forking**. + +## 7. Usage Example Fixtures interact with the parallel manager via the `parallel_manager` fixture. From b652058738b8160ba70c9c4fc353b4c56a4f220c Mon Sep 17 00:00:00 2001 From: Longxiang Lyu Date: Tue, 3 Feb 2026 11:33:42 +0000 Subject: [PATCH 37/43] Remove not-related changes Signed-off-by: Longxiang Lyu --- setup-container.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup-container.sh b/setup-container.sh index d95cd7bb328..905132f5954 100755 --- a/setup-container.sh +++ b/setup-container.sh @@ -369,7 +369,7 @@ function start_local_container() { docker start ${CONTAINER_NAME} else log_info "creating a container: ${CONTAINER_NAME} ..." - eval "docker run --cap-add=SYS_PTRACE -d -t ${PUBLISH_PORTS} ${ENV_VARS} -h ${CONTAINER_NAME} \ + eval "docker run -d -t ${PUBLISH_PORTS} ${ENV_VARS} -h ${CONTAINER_NAME} \ -v \"$(dirname "${SCRIPT_DIR}"):${LINK_DIR}:rslave\" ${MOUNT_POINTS} \ --name \"${CONTAINER_NAME}\" \"${LOCAL_IMAGE}\" /bin/bash ${SILENT_HOOK}" || \ exit_failure "failed to start a container: ${CONTAINER_NAME}" From 05daff1706e4096246984267768305a36414221d Mon Sep 17 00:00:00 2001 From: Longxiang Lyu Date: Tue, 3 Feb 2026 11:37:52 +0000 Subject: [PATCH 38/43] Add sd to doc Signed-off-by: Longxiang Lyu --- .../common/plugins/parallel_fixture/README.md | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/common/plugins/parallel_fixture/README.md b/tests/common/plugins/parallel_fixture/README.md index 30b340a2c48..86b5db90786 100644 --- a/tests/common/plugins/parallel_fixture/README.md +++ b/tests/common/plugins/parallel_fixture/README.md @@ -104,6 +104,42 @@ To prevent this, the framework (in `tests/common/helpers/parallel.py`) leverages This ensures that **locks are always in a released state within the child process immediately after forking**. +```mermaid +sequenceDiagram + participant Main as Main Thread + participant Worker as Parallel Fixture Manager Worker + participant Logger as Logging Module + participant Handler as Log Handler + participant Fork as Fork Operation + participant Child as Child Process + + Note over Main,Child: Before Fix (Deadlock Scenario) + Worker->>Logger: Write log (acquires handler lock) + activate Handler + Main->>Fork: fork() called + Note over Fork: Lock state copied to child + Fork->>Child: Child process created + Note over Child: Child inherits locked handler + Child->>Handler: Attempt to log + Note over Child: DEADLOCK: Lock already held by Parallel Fixture Manager Worker + deactivate Handler + + Note over Main,Child: After Fix (Safe Fork) + Main->>Logger: _fix_logging_handler_fork_lock() + Logger->>Handler: Register at_fork handlers + Note over Handler: before=lock.acquire
after_in_parent=lock.release
after_in_child=lock.release + Main->>Fork: fork() called + Fork->>Handler: Execute before fork (acquire lock) + activate Handler + Fork->>Child: Child process created + Fork->>Handler: Execute after_in_parent (release lock) + deactivate Handler + Fork->>Child: Execute after_in_child (release lock) + Child->>Handler: Attempt to log + Note over Child: SUCCESS: Lock is free + Handler-->>Child: Log written successfully +``` + ## 7. Usage Example Fixtures interact with the parallel manager via the `parallel_manager` fixture. From 0ca3b162c3b2aa06ca4ed424a63d4dbb122ffb2e Mon Sep 17 00:00:00 2001 From: Longxiang Lyu Date: Tue, 3 Feb 2026 11:42:37 +0000 Subject: [PATCH 39/43] Add missing emtpy line Signed-off-by: Longxiang Lyu --- tests/common/devices/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/common/devices/base.py b/tests/common/devices/base.py index 87f2bd01a9c..5644d0e0724 100644 --- a/tests/common/devices/base.py +++ b/tests/common/devices/base.py @@ -149,4 +149,4 @@ def __str__(self): return str(self["host"]) def __repr__(self): - return self.__str__() \ No newline at end of file + return self.__str__() From ca0ae489c69c04a431a97b370fee069eab702b0a Mon Sep 17 00:00:00 2001 From: Longxiang Lyu Date: Thu, 5 Feb 2026 05:45:00 +0000 Subject: [PATCH 40/43] Fix comments Signed-off-by: Longxiang Lyu --- .../common/plugins/parallel_fixture/README.md | 62 ++++++++++++++++++- .../plugins/parallel_fixture/__init__.py | 19 +++++- tests/conftest.py | 2 - 3 files changed, 77 insertions(+), 6 deletions(-) diff --git a/tests/common/plugins/parallel_fixture/README.md b/tests/common/plugins/parallel_fixture/README.md index 86b5db90786..578698223ce 100644 --- a/tests/common/plugins/parallel_fixture/README.md +++ b/tests/common/plugins/parallel_fixture/README.md @@ -16,7 +16,6 @@ The Parallel Fixture Manager is designed to address specific challenges in the S 1. All background tasks associated with a specific scope (Session, Module, Class, Function) in setup must complete successfully before the test runner proceeds to a narrower scope or executes the test function. 2. All background tasks associated with a specific scope (Session, Module, Class, Function) in teardown must complete successfully before the test runner proceeds to a broader scope or finish the test execution. * **Fail-Fast Reliability** - * The system must immediately detect the exception in the background thread and abort the ongoing test setup to prevent cascading failures, resource wastage, and misleading test results. * The system must immediately detect the exception and abort the ongoing test setup to prevent cascading failures, resource wastage, and misleading test results. * **Non-Intrusive Integration** * The system must expose a minimal and intuitive API. Existing fixtures should be able to adopt parallel execution patterns with minimal code changes, preserving the standard pytest fixture structure. @@ -48,6 +47,67 @@ The Parallel Fixture Manager is designed to address specific challenges in the S The manager hooks into the pytest lifecycle to coordinate parallel execution: +```mermaid +sequenceDiagram + participant Pytest + participant ParallelManager + participant ThreadPool + participant MonitorThread + participant Fixture + participant Barrier + + Note over Pytest,Barrier: Session Setup Phase + Pytest->>ParallelManager: Create (session scope) + ParallelManager->>ThreadPool: Initialize worker threads + ParallelManager->>MonitorThread: Start monitoring + + Pytest->>Fixture: Execute fixture (session scope) + Fixture->>ParallelManager: submit_setup_task(SESSION, func) + ParallelManager->>ThreadPool: Submit task to thread pool + ThreadPool-->>ParallelManager: Return future + Fixture-->>Pytest: Yield immediately + + Pytest->>Barrier: setup_barrier_session + Barrier->>ParallelManager: wait_for_setup_tasks(SESSION) + ParallelManager->>ThreadPool: Wait for all session tasks + ThreadPool-->>ParallelManager: Tasks complete + + Note over Pytest,Barrier: Module/Class/Function Scopes + Pytest->>Fixture: Execute fixture (module scope) + Fixture->>ParallelManager: submit_setup_task(MODULE, func) + ParallelManager->>ThreadPool: Submit to pool + Fixture-->>Pytest: Yield + + Pytest->>Barrier: setup_barrier_module + Barrier->>ParallelManager: wait_for_setup_tasks(MODULE) + ParallelManager->>ThreadPool: Wait for module tasks + + Note over Pytest,Barrier: Test Execution + Pytest->>ParallelManager: pytest_runtest_call hook + ParallelManager->>ThreadPool: Ensure all tasks complete + ParallelManager->>ThreadPool: Terminate executor + Pytest->>Pytest: Run test function + + Note over Pytest,Barrier: Teardown Phase + Pytest->>ParallelManager: pytest_runtest_teardown hook + ParallelManager->>ThreadPool: Reset and create new executor + ParallelManager->>MonitorThread: Restart monitoring + + Pytest->>Fixture: Teardown fixture + Fixture->>ParallelManager: submit_teardown_task(scope, func) + ParallelManager->>ThreadPool: Submit teardown task + Fixture-->>Pytest: Return + + Pytest->>Barrier: teardown_barrier_function + Barrier->>ParallelManager: wait_for_teardown_tasks(FUNCTION) + ParallelManager->>ThreadPool: Wait for function teardowns + + Pytest->>ParallelManager: pytest_runtest_logreport hook + ParallelManager->>ThreadPool: Terminate executor + ParallelManager->>MonitorThread: Stop monitoring + +``` + #### Setup Phase 1. **Submission**: Fixtures submit setup functions using `parallel_manager.submit_setup_task(scope, func, *args, **kwargs)`. diff --git a/tests/common/plugins/parallel_fixture/__init__.py b/tests/common/plugins/parallel_fixture/__init__.py index 241c4c10fd8..07610f8590f 100644 --- a/tests/common/plugins/parallel_fixture/__init__.py +++ b/tests/common/plugins/parallel_fixture/__init__.py @@ -156,7 +156,9 @@ def _monitor_workers(self): with self.monitor_lock: done_futures = set() for f in self.active_futures: - future_threads[f.task_context.tid] = f + tid = f.task_context.tid + if tid is not None: + future_threads[tid] = f if f.done(): done_futures.add(f) if f.exception(): @@ -263,7 +265,11 @@ def wrapper(*args, **kwargs): try: return func(*args, **kwargs) except Exception: - raise sys.exc_info()[0](traceback.format_exc()) + _, exc_value, exc_traceback = sys.exc_info() + logging.error("[Parallel Fixture] Task %s exception:\n%s", + task_context.task_name, + traceback.format_exc()) + raise exc_value.with_traceback(exc_traceback) finally: _log_context.prefix = None task_context.end_time = time.time() @@ -351,7 +357,8 @@ def terminate(self): logging.debug("[Parallel Fixture] Running tasks to be terminated: %s", [_.task_name for _ in running_futures]) if running_futures: logging.debug("[Parallel Fixture] Force interrupt thread pool workers") - running_futures_tids = [future.task_context.tid for future in running_futures] + running_futures_tids = [future.task_context.tid for future in running_futures + if future.task_context.tid is not None] for thread in self.executor._threads: if thread.is_alive() and thread.ident in running_futures_tids: raise_async_exception(thread.ident, ParallelTaskTerminatedError) @@ -390,12 +397,18 @@ def reset(self): if not self.terminated: raise RuntimeError("Cannot reset a running parallel fixture manager.") + # Reinitialize buckets for all defined scopes + self.setup_futures = {scope: [] for scope in TaskScope} + self.teardown_futures = {scope: [] for scope in TaskScope} + self.current_scope = None + self.active_futures.clear() self.done_futures.clear() self.executor = ThreadPoolExecutor(max_workers=self.worker_count) self.is_monitor_running = True self.monitor_thread = threading.Thread(target=self._monitor_workers, daemon=True) self.monitor_thread.start() + self.terminated = False def check_for_exception(self): """Check done futures and re-raise any exception.""" diff --git a/tests/conftest.py b/tests/conftest.py index 1be6dc19c03..724ece0e258 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -118,8 +118,6 @@ 'tests.common.fixtures.duthost_utils', 'tests.common.plugins.parallel_fixture') -patch_ansible_worker_process() - patch_ansible_worker_process() fix_logging_handler_fork_lock() From b9cd1fcce341eed79e0cce9f340035c093f3832f Mon Sep 17 00:00:00 2001 From: Longxiang Lyu Date: Thu, 5 Feb 2026 05:49:34 +0000 Subject: [PATCH 41/43] Enable TCP keepalive to avoid broken pipe issue Signed-off-by: Longxiang Lyu --- ansible/ansible.cfg | 3 ++- .../common/plugins/parallel_fixture/README.md | 18 +++++++++--------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/ansible/ansible.cfg b/ansible/ansible.cfg index 5c71740bc39..f73efedfd7f 100644 --- a/ansible/ansible.cfg +++ b/ansible/ansible.cfg @@ -124,6 +124,7 @@ connection_plugins = plugins/connection lookup_plugins = plugins/lookup # vars_plugins = /usr/share/ansible_plugins/vars_plugins filter_plugins = plugins/filter +# Disable profile tasks callback to avoid possible deadlock # callback_whitelist = profile_tasks # by default callbacks are not loaded for /bin/ansible, enable this if you @@ -190,7 +191,7 @@ become_ask_pass=False # ssh arguments to use # Leaving off ControlPersist will result in poor performance, so use # paramiko on older platforms rather than removing it -ssh_args = -o ControlMaster=auto -o ControlPersist=180s -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -o ServerAliveInterval=30 -o ServerAliveCountMax=40 +ssh_args = -o ControlMaster=auto -o ControlPersist=300s -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -o ServerAliveInterval=30 -o ServerAliveCountMax=40 -o TCPKeepAlive=yes # The path to use for the ControlPath sockets. This defaults to diff --git a/tests/common/plugins/parallel_fixture/README.md b/tests/common/plugins/parallel_fixture/README.md index 578698223ce..60219b003dd 100644 --- a/tests/common/plugins/parallel_fixture/README.md +++ b/tests/common/plugins/parallel_fixture/README.md @@ -60,48 +60,48 @@ sequenceDiagram Pytest->>ParallelManager: Create (session scope) ParallelManager->>ThreadPool: Initialize worker threads ParallelManager->>MonitorThread: Start monitoring - + Pytest->>Fixture: Execute fixture (session scope) Fixture->>ParallelManager: submit_setup_task(SESSION, func) ParallelManager->>ThreadPool: Submit task to thread pool ThreadPool-->>ParallelManager: Return future Fixture-->>Pytest: Yield immediately - + Pytest->>Barrier: setup_barrier_session Barrier->>ParallelManager: wait_for_setup_tasks(SESSION) ParallelManager->>ThreadPool: Wait for all session tasks ThreadPool-->>ParallelManager: Tasks complete - + Note over Pytest,Barrier: Module/Class/Function Scopes Pytest->>Fixture: Execute fixture (module scope) Fixture->>ParallelManager: submit_setup_task(MODULE, func) ParallelManager->>ThreadPool: Submit to pool Fixture-->>Pytest: Yield - + Pytest->>Barrier: setup_barrier_module Barrier->>ParallelManager: wait_for_setup_tasks(MODULE) ParallelManager->>ThreadPool: Wait for module tasks - + Note over Pytest,Barrier: Test Execution Pytest->>ParallelManager: pytest_runtest_call hook ParallelManager->>ThreadPool: Ensure all tasks complete ParallelManager->>ThreadPool: Terminate executor Pytest->>Pytest: Run test function - + Note over Pytest,Barrier: Teardown Phase Pytest->>ParallelManager: pytest_runtest_teardown hook ParallelManager->>ThreadPool: Reset and create new executor ParallelManager->>MonitorThread: Restart monitoring - + Pytest->>Fixture: Teardown fixture Fixture->>ParallelManager: submit_teardown_task(scope, func) ParallelManager->>ThreadPool: Submit teardown task Fixture-->>Pytest: Return - + Pytest->>Barrier: teardown_barrier_function Barrier->>ParallelManager: wait_for_teardown_tasks(FUNCTION) ParallelManager->>ThreadPool: Wait for function teardowns - + Pytest->>ParallelManager: pytest_runtest_logreport hook ParallelManager->>ThreadPool: Terminate executor ParallelManager->>MonitorThread: Stop monitoring From 63d7858cefd8ae669db7f40372343fb730fc985e Mon Sep 17 00:00:00 2001 From: Longxiang Lyu Date: Tue, 10 Feb 2026 06:02:34 +0000 Subject: [PATCH 42/43] Fix config Signed-off-by: Longxiang Lyu --- ansible/ansible.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ansible/ansible.cfg b/ansible/ansible.cfg index f73efedfd7f..639e3f90c35 100644 --- a/ansible/ansible.cfg +++ b/ansible/ansible.cfg @@ -191,7 +191,7 @@ become_ask_pass=False # ssh arguments to use # Leaving off ControlPersist will result in poor performance, so use # paramiko on older platforms rather than removing it -ssh_args = -o ControlMaster=auto -o ControlPersist=300s -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -o ServerAliveInterval=30 -o ServerAliveCountMax=40 -o TCPKeepAlive=yes +ssh_args = -o ControlMaster=auto -o ControlPersist=180s -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -o ServerAliveInterval=30 -o ServerAliveCountMax=40 # The path to use for the ControlPath sockets. This defaults to From 9f0c4af73b1d9575817e49fbf1d3189a8dc77496 Mon Sep 17 00:00:00 2001 From: Longxiang Lyu Date: Tue, 10 Feb 2026 06:30:52 +0000 Subject: [PATCH 43/43] Fix ansible config Signed-off-by: Longxiang Lyu --- ansible/ansible.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ansible/ansible.cfg b/ansible/ansible.cfg index 639e3f90c35..f73efedfd7f 100644 --- a/ansible/ansible.cfg +++ b/ansible/ansible.cfg @@ -191,7 +191,7 @@ become_ask_pass=False # ssh arguments to use # Leaving off ControlPersist will result in poor performance, so use # paramiko on older platforms rather than removing it -ssh_args = -o ControlMaster=auto -o ControlPersist=180s -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -o ServerAliveInterval=30 -o ServerAliveCountMax=40 +ssh_args = -o ControlMaster=auto -o ControlPersist=300s -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -o ServerAliveInterval=30 -o ServerAliveCountMax=40 -o TCPKeepAlive=yes # The path to use for the ControlPath sockets. This defaults to