Skip to content

Commit 7e24463

Browse files
authored
[chassis]: remote cli commands infra for sonic chassis (sonic-net#2701)
What I did Since each Linecard is running an independent SONiC Instance, the user needs to login to a linecard to run any CLI command The user can login to each Linecard 2 ways Ssh directly to the linecard using the management IP address Ssh to supervisor and from supervisor ssh to the Linecard using the Linecard’s internal IP address To simplify the user experience and allow scripting agents to execute commands on all linecards. Two new commands are being added rexec <linecard_name|all> -c <cli_command> This command will execute the command on specified linecards or all linecards. rshell <linecard_name> connects to the linecard for interactive shell How to verify it UT and testing in the chassis UT results for new files rcli/init.py 0 0 0 0 100% rcli/linecard.py 82 8 16 2 88% rcli/rexec.py 28 2 10 1 92% rcli/rshell.py 25 3 6 2 84% rcli/utils.py 78 6 26 2 90% Signed-off-by: Arvindsrinivasan Lakshmi Narasimhan <[email protected]>
1 parent bee593e commit 7e24463

File tree

14 files changed

+722
-8
lines changed

14 files changed

+722
-8
lines changed

rcli/__init__.py

Whitespace-only changes.

rcli/linecard.py

+151
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import click
2+
import os
3+
import paramiko
4+
import sys
5+
import select
6+
import socket
7+
import sys
8+
import termios
9+
import tty
10+
11+
from .utils import get_linecard_ip
12+
from paramiko.py3compat import u
13+
from paramiko import Channel
14+
15+
EMPTY_OUTPUTS = ['', '\x1b[?2004l\r']
16+
17+
class Linecard:
18+
19+
def __init__(self, linecard_name, username, password):
20+
"""
21+
Initialize Linecard object and store credentials, connection, and channel
22+
23+
:param linecard_name: The name of the linecard you want to connect to
24+
:param username: The username to use to connect to the linecard
25+
:param password: The linecard password. If password not provided, it
26+
will prompt the user for it
27+
:param use_ssh_keys: Whether or not to use SSH keys to authenticate.
28+
"""
29+
self.ip = get_linecard_ip(linecard_name)
30+
31+
if not self.ip:
32+
sys.exit(1)
33+
34+
self.linecard_name = linecard_name
35+
self.username = username
36+
self.password = password
37+
38+
self.connection = self._connect()
39+
40+
41+
def _connect(self):
42+
connection = paramiko.SSHClient()
43+
# if ip address not in known_hosts, ignore known_hosts error
44+
connection.set_missing_host_key_policy(paramiko.AutoAddPolicy())
45+
try:
46+
connection.connect(self.ip, username=self.username, password=self.password)
47+
except paramiko.ssh_exception.NoValidConnectionsError as e:
48+
connection = None
49+
click.echo(e)
50+
return connection
51+
52+
def _get_password(self):
53+
"""
54+
Prompts the user for a password, and returns the password
55+
56+
:param username: The username that we want to get the password for
57+
:type username: str
58+
:return: The password for the username.
59+
"""
60+
61+
return getpass(
62+
"Password for username '{}': ".format(self.username),
63+
# Pass in click stdout stream - this is similar to using click.echo
64+
stream=click.get_text_stream('stdout')
65+
)
66+
67+
def _set_tty_params(self):
68+
tty.setraw(sys.stdin.fileno())
69+
tty.setcbreak(sys.stdin.fileno())
70+
71+
def _is_data_to_read(self, read):
72+
if self.channel in read:
73+
return True
74+
return False
75+
76+
def _is_data_to_write(self, read):
77+
if sys.stdin in read:
78+
return True
79+
return False
80+
81+
def _write_to_terminal(self, data):
82+
# Write channel output to terminal
83+
sys.stdout.write(data)
84+
sys.stdout.flush()
85+
86+
def _start_interactive_shell(self):
87+
oldtty = termios.tcgetattr(sys.stdin)
88+
try:
89+
self._set_tty_params()
90+
self.channel.settimeout(0.0)
91+
92+
while True:
93+
#Continuously wait for commands and execute them
94+
read, write, ex = select.select([self.channel, sys.stdin], [], [])
95+
if self._is_data_to_read(read):
96+
try:
97+
# Get output from channel
98+
x = u(self.channel.recv(1024))
99+
if len(x) == 0:
100+
# logout message will be displayed
101+
break
102+
self._write_to_terminal(x)
103+
except socket.timeout as e:
104+
click.echo("Connection timed out")
105+
break
106+
if self._is_data_to_write(read):
107+
# If we are able to send input, get the input from stdin
108+
x = sys.stdin.read(1)
109+
if len(x) == 0:
110+
break
111+
# Send the input to the channel
112+
self.channel.send(x)
113+
finally:
114+
# Now that the channel has been exited, return to the previously-saved old tty
115+
termios.tcsetattr(sys.stdin, termios.TCSADRAIN, oldtty)
116+
pass
117+
118+
119+
def start_shell(self) -> None:
120+
"""
121+
Opens a session, gets a pseudo-terminal, invokes a shell, and then
122+
attaches the host shell to the remote shell.
123+
"""
124+
# Create shell session
125+
self.channel = self.connection.get_transport().open_session()
126+
self.channel.get_pty()
127+
self.channel.invoke_shell()
128+
# Use Paramiko Interactive script to connect to the shell
129+
self._start_interactive_shell()
130+
# After user exits interactive shell, close the connection
131+
self.connection.close()
132+
133+
134+
def execute_cmd(self, command) -> str:
135+
"""
136+
Takes a command as an argument, executes it on the remote shell, and returns the output
137+
138+
:param command: The command to execute on the remote shell
139+
:return: The output of the command.
140+
"""
141+
# Execute the command and gather errors and output
142+
_, stdout, stderr = self.connection.exec_command(command + "\n")
143+
output = stdout.read().decode('utf-8')
144+
145+
if stderr:
146+
# Error was present, add message to output
147+
output += stderr.read().decode('utf-8')
148+
149+
# Close connection and return output
150+
self.connection.close()
151+
return output

rcli/rexec.py

+44
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import os
2+
import click
3+
import paramiko
4+
import sys
5+
6+
from .linecard import Linecard
7+
from rcli import utils as rcli_utils
8+
from sonic_py_common import device_info
9+
10+
@click.command()
11+
@click.argument('linecard_names', nargs=-1, type=str, required=True)
12+
@click.option('-c', '--command', type=str, required=True)
13+
def cli(linecard_names, command):
14+
"""
15+
Executes a command on one or many linecards
16+
17+
:param linecard_names: A list of linecard names to execute the command on,
18+
use `all` to execute on all linecards.
19+
:param command: The command to execute on the linecard(s)
20+
"""
21+
if not device_info.is_chassis():
22+
click.echo("This commmand is only supported Chassis")
23+
sys.exit(1)
24+
25+
username = os.getlogin()
26+
password = rcli_utils.get_password(username)
27+
28+
if list(linecard_names) == ["all"]:
29+
# Get all linecard names using autocompletion helper
30+
linecard_names = rcli_utils.get_all_linecards(None, None, "")
31+
32+
# Iterate through each linecard, execute command, and gather output
33+
for linecard_name in linecard_names:
34+
try:
35+
lc = Linecard(linecard_name, username, password)
36+
if lc.connection:
37+
# If connection was created, connection exists. Otherwise, user will see an error message.
38+
click.echo("======== {} output: ========".format(lc.linecard_name))
39+
click.echo(lc.execute_cmd(command))
40+
except paramiko.ssh_exception.AuthenticationException:
41+
click.echo("Login failed on '{}' with username '{}'".format(linecard_name, lc.username))
42+
43+
if __name__=="__main__":
44+
cli(prog_name='rexec')

rcli/rshell.py

+38
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import os
2+
import click
3+
import paramiko
4+
import sys
5+
6+
from .linecard import Linecard
7+
from sonic_py_common import device_info
8+
from rcli import utils as rcli_utils
9+
10+
11+
@click.command()
12+
@click.argument('linecard_name', type=str, autocompletion=rcli_utils.get_all_linecards)
13+
def cli(linecard_name):
14+
"""
15+
Open interactive shell for one linecard
16+
17+
:param linecard_name: The name of the linecard to connect to
18+
"""
19+
if not device_info.is_chassis():
20+
click.echo("This commmand is only supported Chassis")
21+
sys.exit(1)
22+
23+
username = os.getlogin()
24+
password = rcli_utils.get_password(username)
25+
26+
try:
27+
lc =Linecard(linecard_name, username, password)
28+
if lc.connection:
29+
click.echo("Connecting to {}".format(lc.linecard_name))
30+
# If connection was created, connection exists. Otherwise, user will see an error message.
31+
lc.start_shell()
32+
click.echo("Connection Closed")
33+
except paramiko.ssh_exception.AuthenticationException:
34+
click.echo("Login failed on '{}' with username '{}'".format(linecard_name, lc.username))
35+
36+
37+
if __name__=="__main__":
38+
cli(prog_name='rshell')

rcli/utils.py

+149
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import click
2+
from getpass import getpass
3+
import os
4+
import sys
5+
6+
from swsscommon.swsscommon import SonicV2Connector
7+
8+
CHASSIS_MODULE_INFO_TABLE = 'CHASSIS_MODULE_TABLE'
9+
CHASSIS_MODULE_INFO_KEY_TEMPLATE = 'CHASSIS_MODULE {}'
10+
CHASSIS_MODULE_INFO_DESC_FIELD = 'desc'
11+
CHASSIS_MODULE_INFO_SLOT_FIELD = 'slot'
12+
CHASSIS_MODULE_INFO_OPERSTATUS_FIELD = 'oper_status'
13+
CHASSIS_MODULE_INFO_ADMINSTATUS_FIELD = 'admin_status'
14+
15+
CHASSIS_MIDPLANE_INFO_TABLE = 'CHASSIS_MIDPLANE_TABLE'
16+
CHASSIS_MIDPLANE_INFO_IP_FIELD = 'ip_address'
17+
CHASSIS_MIDPLANE_INFO_ACCESS_FIELD = 'access'
18+
19+
CHASSIS_MODULE_HOSTNAME_TABLE = 'CHASSIS_MODULE_HOSTNAME_TABLE'
20+
CHASSIS_MODULE_HOSTNAME = 'module_hostname'
21+
22+
def connect_to_chassis_state_db():
23+
chassis_state_db = SonicV2Connector(host="127.0.0.1")
24+
chassis_state_db.connect(chassis_state_db.CHASSIS_STATE_DB)
25+
return chassis_state_db
26+
27+
28+
def connect_state_db():
29+
state_db = SonicV2Connector(host="127.0.0.1")
30+
state_db.connect(state_db.STATE_DB)
31+
return state_db
32+
33+
34+
35+
def get_linecard_module_name_from_hostname(linecard_name: str):
36+
37+
chassis_state_db = connect_to_chassis_state_db()
38+
39+
keys = chassis_state_db.keys(chassis_state_db.CHASSIS_STATE_DB , '{}|{}'.format(CHASSIS_MODULE_HOSTNAME_TABLE, '*'))
40+
for key in keys:
41+
module_name = key.split('|')[1]
42+
hostname = chassis_state_db.get(chassis_state_db.CHASSIS_STATE_DB, key, CHASSIS_MODULE_HOSTNAME)
43+
if hostname.replace('-', '').lower() == linecard_name.replace('-', '').lower():
44+
return module_name
45+
46+
return None
47+
48+
def get_linecard_ip(linecard_name: str):
49+
"""
50+
Given a linecard name, lookup its IP address in the midplane table
51+
52+
:param linecard_name: The name of the linecard you want to connect to
53+
:type linecard_name: str
54+
:return: IP address of the linecard
55+
"""
56+
# Adapted from `show chassis modules midplane-status` command logic:
57+
# https://github.com/sonic-net/sonic-utilities/blob/master/show/chassis_modules.py
58+
59+
# if the user passes linecard hostname, then try to get the module name for that linecard
60+
module_name = get_linecard_module_name_from_hostname(linecard_name)
61+
# if the module name cannot be found from host, assume the user has passed module name
62+
if module_name is None:
63+
module_name = linecard_name
64+
module_ip, module_access = get_module_ip_and_access_from_state_db(module_name)
65+
66+
if not module_ip:
67+
click.echo('Linecard {} not found'.format(linecard_name))
68+
return None
69+
70+
if module_access != 'True':
71+
click.echo('Linecard {} not accessible'.format(linecard_name))
72+
return None
73+
74+
75+
return module_ip
76+
77+
def get_module_ip_and_access_from_state_db(module_name):
78+
state_db = connect_state_db()
79+
data_dict = state_db.get_all(
80+
state_db.STATE_DB, '{}|{}'.format(CHASSIS_MIDPLANE_INFO_TABLE,module_name ))
81+
if data_dict is None:
82+
return None, None
83+
84+
linecard_ip = data_dict.get(CHASSIS_MIDPLANE_INFO_IP_FIELD, None)
85+
access = data_dict.get(CHASSIS_MIDPLANE_INFO_ACCESS_FIELD, None)
86+
87+
return linecard_ip, access
88+
89+
90+
def get_all_linecards(ctx, args, incomplete) -> list:
91+
"""
92+
Return a list of all accessible linecard names. This function is used to
93+
autocomplete linecard names in the CLI.
94+
95+
:param ctx: The Click context object that is passed to the command function
96+
:param args: The arguments passed to the Click command
97+
:param incomplete: The string that the user has typed so far
98+
:return: A list of all accessible linecard names.
99+
"""
100+
# Adapted from `show chassis modules midplane-status` command logic:
101+
# https://github.com/sonic-net/sonic-utilities/blob/master/show/chassis_modules.py
102+
103+
104+
chassis_state_db = connect_to_chassis_state_db()
105+
state_db = connect_state_db()
106+
107+
linecards = []
108+
keys = state_db.keys(state_db.STATE_DB,'{}|*'.format(CHASSIS_MIDPLANE_INFO_TABLE))
109+
for key in keys:
110+
key_list = key.split('|')
111+
if len(key_list) != 2: # error data in DB, log it and ignore
112+
click.echo('Warn: Invalid Key {} in {} table'.format(key, CHASSIS_MIDPLANE_INFO_TABLE ))
113+
continue
114+
module_name = key_list[1]
115+
linecard_ip, access = get_module_ip_and_access_from_state_db(module_name)
116+
if linecard_ip is None:
117+
continue
118+
119+
if access != "True" :
120+
continue
121+
122+
# get the hostname for this module
123+
hostname = chassis_state_db.get(chassis_state_db.CHASSIS_STATE_DB, '{}|{}'.format(CHASSIS_MODULE_HOSTNAME_TABLE, module_name), CHASSIS_MODULE_HOSTNAME)
124+
if hostname:
125+
linecards.append(hostname)
126+
else:
127+
linecards.append(module_name)
128+
129+
# Return a list of all matched linecards
130+
return [lc for lc in linecards if incomplete in lc]
131+
132+
133+
def get_password(username=None):
134+
"""
135+
Prompts the user for a password, and returns the password
136+
137+
:param username: The username that we want to get the password for
138+
:type username: str
139+
:return: The password for the username.
140+
"""
141+
142+
if username is None:
143+
username =os.getlogin()
144+
145+
return getpass(
146+
"Password for username '{}': ".format(username),
147+
# Pass in click stdout stream - this is similar to using click.echo
148+
stream=click.get_text_stream('stdout')
149+
)

0 commit comments

Comments
 (0)