Skip to content

Commit 373ba7a

Browse files
tests-common: Handle PortInUseException for SSHConsoleConn (sonic-net#15174)
What is the motivation for this PR? This PR is intended to add more resilience to the dut_console tests, as if a testbed with a blocked port was selected for a nightly test, it would cause the dut_console tests to fail during setup. This provides a method for auto-recovery for these cases. How did you do it? By completing the following: Add connection types for each of the configuration menu types (Digi, Cisco, and Sonic) to allow for different command sequences to be accounted for Add function to clear a DUT's console port Add retry logic to add resilience to main DUT connection set up How did you verify/test it? This process was conducted on 5 testbeds, with at least one of each config menu type. Steps conducted during testing SSH into testbed using the console IP and console port - simulating a blocked port In a separate terminal, run any dut_console test individually During test setup, the connection from step 1 was terminated successfully The test then runs successfully Example test output Successful port reset (Digi config): Screenshot 2024-10-25 165601 Successful port reset (Sonic config): Screenshot 2024-10-25 183646 Sample failure Simulating config menu type is not defined (causing the port clear function to exit early), and a more descriptive error message is thrown regarding why the connection to the DUT is failing: Screenshot 2024-10-25 153008 Any platform specific information? N/A - this is a generalised solution which accounts for as many types of configuration menus as possible.
1 parent 2aa4b12 commit 373ba7a

6 files changed

Lines changed: 161 additions & 22 deletions

File tree

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
StartDevice,StartPort,EndDevice,Console_type,Proxy,BaudRate
2-
console-1,10,str-msn2700-01,ssh,,9600
3-
console-2,11,str-7260-10,ssh,,9600
4-
console-1,12,str-7260-11,ssh,,
5-
management-1,13,str-acs-serv-01,ssh,,9600
1+
StartDevice,StartPort,EndDevice,Console_type,Console_menu_type,Proxy,BaudRate
2+
console-1,10,str-msn2700-01,ssh,,,9600
3+
console-2,11,str-7260-10,ssh,,,9600
4+
console-1,12,str-7260-11,ssh,,,
5+
management-1,13,str-acs-serv-01,ssh,,,9600

ansible/library/conn_graph_facts.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,7 @@ def csv_to_graph_facts(self):
349349
"peerport": entry["StartPort"],
350350
"proxy": entry["Proxy"],
351351
"type": entry["Console_type"],
352+
"menu_type": entry["Console_menu_type"],
352353
}
353354
}
354355
self.graph_facts["console_links"] = console_links

tests/common/connections/base_console_conn.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@
2121
CONSOLE_SSH = "console_ssh"
2222
# Console login via SSH, then login to devices by 'menu ports'
2323
CONSOLE_SSH_MENU_PORTS = "console_ssh_menu_ports"
24+
# Console login via SSH, no stage 2 login (Digi Config Menu)
25+
CONSOLE_SSH_DIGI_CONFIG = "console_ssh_digi_config"
26+
# Console login via SSH, no stage 2 login (SONiC switch config)
27+
CONSOLE_SSH_SONIC_CONFIG = "console_ssh_sonic_config"
28+
# Console login via SSH, no stage 2 login (Cisco switch config)
29+
CONSOLE_SSH_CISCO_CONFIG = "console_ssh_cisco_config"
2430

2531

2632
class BaseConsoleConn(CiscoBaseConnection):

tests/common/connections/console_host.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,21 @@
1-
from .base_console_conn import CONSOLE_SSH, CONSOLE_SSH_MENU_PORTS, CONSOLE_TELNET
1+
from .base_console_conn import (
2+
CONSOLE_SSH,
3+
CONSOLE_SSH_CISCO_CONFIG,
4+
CONSOLE_SSH_MENU_PORTS,
5+
CONSOLE_TELNET,
6+
CONSOLE_SSH_DIGI_CONFIG,
7+
CONSOLE_SSH_SONIC_CONFIG
8+
)
29
from .telnet_console_conn import TelnetConsoleConn
310
from .ssh_console_conn import SSHConsoleConn
411

512
ConsoleTypeMapper = {
613
CONSOLE_TELNET: TelnetConsoleConn,
714
CONSOLE_SSH: SSHConsoleConn,
8-
CONSOLE_SSH_MENU_PORTS: SSHConsoleConn
15+
CONSOLE_SSH_MENU_PORTS: SSHConsoleConn,
16+
CONSOLE_SSH_DIGI_CONFIG: SSHConsoleConn,
17+
CONSOLE_SSH_SONIC_CONFIG: SSHConsoleConn,
18+
CONSOLE_SSH_CISCO_CONFIG: SSHConsoleConn,
919
}
1020

1121

tests/common/connections/ssh_console_conn.py

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import time
22
import re
3-
from .base_console_conn import BaseConsoleConn, CONSOLE_SSH
3+
from .base_console_conn import CONSOLE_SSH_DIGI_CONFIG, BaseConsoleConn, CONSOLE_SSH
44
from netmiko.ssh_exception import NetMikoAuthenticationException
55
from paramiko.ssh_exception import SSHException
66

@@ -15,10 +15,18 @@ def __init__(self, **kwargs):
1515
self.sonic_username = kwargs['sonic_username']
1616
self.sonic_password = kwargs['sonic_password']
1717

18-
if kwargs['console_type'] == CONSOLE_SSH:
18+
# Store console type for later use
19+
self.console_type = kwargs['console_type']
20+
21+
if self.console_type == CONSOLE_SSH:
22+
# Login requires port to be provided
1923
kwargs['username'] = kwargs['console_username'] + r':' + str(kwargs['console_port'])
2024
self.menu_port = None
25+
elif self.console_type.endswith("config"):
26+
# Login to config menu only requires username
27+
kwargs['username'] = kwargs['console_username']
2128
else:
29+
# Login requires menu port
2230
kwargs['username'] = kwargs['console_username']
2331
self.menu_port = kwargs['console_port']
2432
kwargs['password'] = kwargs['console_password']
@@ -30,10 +38,19 @@ def session_preparation(self):
3038
session_init_msg = self._test_channel_read()
3139
self.logger.debug(session_init_msg)
3240

33-
if re.search(r"Port is in use. Closing connection...", session_init_msg, flags=re.M):
41+
if re.search(
42+
r"(Port is in use. Closing connection...|Cannot connect: line \[\d{2}\] is busy)",
43+
session_init_msg,
44+
flags=re.M
45+
):
3446
console_port = self.username.split(':')[-1]
3547
raise PortInUseException(f"Host closed connection, as console port '{console_port}' is currently occupied.")
3648

49+
if self.console_type.endswith("config"):
50+
# We can skip stage 2 login for config menu connections
51+
self.session_preparation_finalise()
52+
return
53+
3754
if (self.menu_port):
3855
# For devices logining via menu port, 2 additional login are needed
3956
# Since we have attempted all passwords in __init__ of base class until successful login
@@ -54,7 +71,18 @@ def session_preparation(self):
5471
else:
5572
break
5673

57-
self.set_base_prompt()
74+
self.session_preparation_finalise()
75+
76+
def session_preparation_finalise(self):
77+
"""
78+
Helper function to handle final stages of session preparation.
79+
"""
80+
# Digi config menu has a unique prompt terminator (----->)
81+
if self.console_type == CONSOLE_SSH_DIGI_CONFIG:
82+
self.set_base_prompt(">")
83+
else:
84+
self.set_base_prompt()
85+
5886
# Clear the read buffer
5987
time.sleep(0.3 * self.global_delay_factor)
6088
self.clear_buffer()
@@ -151,9 +179,10 @@ def login_stage_2(self,
151179
raise NetMikoAuthenticationException(msg)
152180

153181
def cleanup(self):
154-
# Send an exit to logout from SONiC
155-
self.send_command(command_string="exit",
156-
expect_string="login:")
182+
# If we are in SONiC, send an exit to logout
183+
if not self.console_type.endswith("config"):
184+
self.send_command(command_string="exit",
185+
expect_string="login:")
157186
# remote_conn must be closed, or the SSH session will be kept on Digi,
158187
# and any other login is prevented
159188
self.remote_conn.close()

tests/conftest.py

Lines changed: 101 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@
1818

1919
from datetime import datetime
2020
from ipaddress import ip_interface, IPv4Interface
21+
from tests.common.connections.base_console_conn import (
22+
CONSOLE_SSH_CISCO_CONFIG,
23+
CONSOLE_SSH_DIGI_CONFIG,
24+
CONSOLE_SSH_SONIC_CONFIG
25+
)
2126
from tests.common.fixtures.conn_graph_facts import conn_graph_facts # noqa F401
2227
from tests.common.devices.local import Localhost
2328
from tests.common.devices.ptf import PTFHost
@@ -1883,24 +1888,112 @@ def duthost_console(duthosts, enum_supervisor_dut_hostname, localhost, conn_grap
18831888
console_host = console_host.split("/")[0]
18841889
console_port = conn_graph_facts['device_console_link'][dut_hostname]['ConsolePort']['peerport']
18851890
console_type = conn_graph_facts['device_console_link'][dut_hostname]['ConsolePort']['type']
1891+
console_menu_type = conn_graph_facts['device_console_link'][dut_hostname]['ConsolePort']['menu_type']
18861892
console_username = conn_graph_facts['device_console_link'][dut_hostname]['ConsolePort']['proxy']
18871893

1888-
console_type = "console_" + console_type
1894+
console_type = f"console_{console_type}"
1895+
console_menu_type = f"{console_type}_{console_menu_type}"
18891896

18901897
# console password and sonic_password are lists, which may contain more than one password
18911898
sonicadmin_alt_password = localhost.host.options['variable_manager']._hostvars[dut_hostname].get(
18921899
"ansible_altpassword")
1893-
host = ConsoleHost(console_type=console_type,
1894-
console_host=console_host,
1895-
console_port=console_port,
1896-
sonic_username=creds['sonicadmin_user'],
1897-
sonic_password=[creds['sonicadmin_password'], sonicadmin_alt_password],
1898-
console_username=console_username,
1899-
console_password=creds['console_password'][console_type])
1900+
sonic_password = [creds['sonicadmin_password'], sonicadmin_alt_password]
1901+
1902+
# Attempt to clear the console port
1903+
try:
1904+
duthost_clear_console_port(
1905+
menu_type=console_menu_type,
1906+
console_host=console_host,
1907+
console_port=console_port,
1908+
console_username=console_username,
1909+
console_password=creds['console_password'][console_type]
1910+
)
1911+
except Exception as e:
1912+
logger.warning(f"Issue trying to clear console port: {e}")
1913+
1914+
# Set up console host
1915+
host = None
1916+
for attempt in range(1, 4):
1917+
try:
1918+
host = ConsoleHost(console_type=console_type,
1919+
console_host=console_host,
1920+
console_port=console_port,
1921+
sonic_username=creds['sonicadmin_user'],
1922+
sonic_password=sonic_password,
1923+
console_username=console_username,
1924+
console_password=creds['console_password'][console_type])
1925+
break
1926+
except Exception as e:
1927+
logger.warning(f"Attempt {attempt}/3 failed: {e}")
1928+
continue
1929+
else:
1930+
raise Exception("Failed to set up connection to console port. See warning logs for details.")
1931+
19001932
yield host
19011933
host.disconnect()
19021934

19031935

1936+
def duthost_clear_console_port(
1937+
menu_type: str,
1938+
console_host: str,
1939+
console_port: str,
1940+
console_username: str,
1941+
console_password: str
1942+
):
1943+
"""
1944+
Helper function to clear the console port for a given DUT.
1945+
Useful when a device has an occupied console port, preventing dut_console tests from running.
1946+
1947+
Parameters:
1948+
menu_type: Connection type for the console's config menu (as expected by the ConsoleTypeMapper)
1949+
console_host: DUT host's console IP address
1950+
console_port: DUT host's console port, to be cleared
1951+
console_username: Username for the console account (overridden for Digi console)
1952+
console_password: Password for the console account
1953+
"""
1954+
if menu_type == "console_ssh_":
1955+
raise Exception("Device does not have a defined Console_menu_type.")
1956+
1957+
# Override console user if the configuration menu is Digi, as this requires admin login
1958+
console_user = 'admin' if menu_type == CONSOLE_SSH_DIGI_CONFIG else console_username
1959+
1960+
duthost_config_menu = ConsoleHost(
1961+
console_type=menu_type,
1962+
console_host=console_host,
1963+
console_port=console_port,
1964+
console_username=console_user,
1965+
console_password=console_password,
1966+
sonic_username=None,
1967+
sonic_password=None
1968+
)
1969+
1970+
# Command lists for each config menu type
1971+
# List of tuples, containing a command to execute, and an optional pattern to wait for
1972+
command_list = {
1973+
CONSOLE_SSH_DIGI_CONFIG: [
1974+
('2', None), # Enter serial port config
1975+
(console_port, None), # Choose DUT console port
1976+
('a', None), # Enter port management
1977+
('1', f'Port #{console_port} has been reset successfully.') # Reset chosen port
1978+
],
1979+
CONSOLE_SSH_SONIC_CONFIG: [
1980+
(f'sudo sonic-clear line {console_port}', None) # Clear DUT console port (requires sudo)
1981+
],
1982+
CONSOLE_SSH_CISCO_CONFIG: [
1983+
(f'clear line tty {console_port}', '[confirm]'), # Clear DUT console port
1984+
('', '[OK]') # Confirm selection
1985+
],
1986+
}
1987+
1988+
for command, wait_for_pattern in command_list[menu_type]:
1989+
duthost_config_menu.write_channel(command + duthost_config_menu.RETURN)
1990+
duthost_config_menu.read_until_prompt_or_pattern(wait_for_pattern)
1991+
1992+
duthost_config_menu.disconnect()
1993+
logger.info(f"Successfully cleared console port {console_port}, sleeping for 5 seconds")
1994+
time.sleep(5)
1995+
1996+
19041997
@pytest.fixture(scope='session')
19051998
def cleanup_cache_for_session(request):
19061999
"""

0 commit comments

Comments
 (0)