Skip to content

Commit

Permalink
Revert "Revert "[chassis]: remote cli commands infra for sonic chassis (
Browse files Browse the repository at this point in the history
sonic-net#2701)" (sonic-net#2832)"

This reverts commit 3fb3258.
  • Loading branch information
arlakshm committed May 23, 2023
1 parent 3d89589 commit b01a94e
Show file tree
Hide file tree
Showing 14 changed files with 722 additions and 8 deletions.
Empty file added rcli/__init__.py
Empty file.
151 changes: 151 additions & 0 deletions rcli/linecard.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import click
import os
import paramiko
import sys
import select
import socket
import sys
import termios
import tty

from .utils import get_linecard_ip
from paramiko.py3compat import u
from paramiko import Channel

EMPTY_OUTPUTS = ['', '\x1b[?2004l\r']

class Linecard:

def __init__(self, linecard_name, username, password):
"""
Initialize Linecard object and store credentials, connection, and channel
:param linecard_name: The name of the linecard you want to connect to
:param username: The username to use to connect to the linecard
:param password: The linecard password. If password not provided, it
will prompt the user for it
:param use_ssh_keys: Whether or not to use SSH keys to authenticate.
"""
self.ip = get_linecard_ip(linecard_name)

if not self.ip:
sys.exit(1)

self.linecard_name = linecard_name
self.username = username
self.password = password

self.connection = self._connect()


def _connect(self):
connection = paramiko.SSHClient()
# if ip address not in known_hosts, ignore known_hosts error
connection.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
connection.connect(self.ip, username=self.username, password=self.password)
except paramiko.ssh_exception.NoValidConnectionsError as e:
connection = None
click.echo(e)
return connection

def _get_password(self):
"""
Prompts the user for a password, and returns the password
:param username: The username that we want to get the password for
:type username: str
:return: The password for the username.
"""

return getpass(
"Password for username '{}': ".format(self.username),
# Pass in click stdout stream - this is similar to using click.echo
stream=click.get_text_stream('stdout')
)

def _set_tty_params(self):
tty.setraw(sys.stdin.fileno())
tty.setcbreak(sys.stdin.fileno())

def _is_data_to_read(self, read):
if self.channel in read:
return True
return False

def _is_data_to_write(self, read):
if sys.stdin in read:
return True
return False

def _write_to_terminal(self, data):
# Write channel output to terminal
sys.stdout.write(data)
sys.stdout.flush()

def _start_interactive_shell(self):
oldtty = termios.tcgetattr(sys.stdin)
try:
self._set_tty_params()
self.channel.settimeout(0.0)

while True:
#Continuously wait for commands and execute them
read, write, ex = select.select([self.channel, sys.stdin], [], [])
if self._is_data_to_read(read):
try:
# Get output from channel
x = u(self.channel.recv(1024))
if len(x) == 0:
# logout message will be displayed
break
self._write_to_terminal(x)
except socket.timeout as e:
click.echo("Connection timed out")
break
if self._is_data_to_write(read):
# If we are able to send input, get the input from stdin
x = sys.stdin.read(1)
if len(x) == 0:
break
# Send the input to the channel
self.channel.send(x)
finally:
# Now that the channel has been exited, return to the previously-saved old tty
termios.tcsetattr(sys.stdin, termios.TCSADRAIN, oldtty)
pass


def start_shell(self) -> None:
"""
Opens a session, gets a pseudo-terminal, invokes a shell, and then
attaches the host shell to the remote shell.
"""
# Create shell session
self.channel = self.connection.get_transport().open_session()
self.channel.get_pty()
self.channel.invoke_shell()
# Use Paramiko Interactive script to connect to the shell
self._start_interactive_shell()
# After user exits interactive shell, close the connection
self.connection.close()


def execute_cmd(self, command) -> str:
"""
Takes a command as an argument, executes it on the remote shell, and returns the output
:param command: The command to execute on the remote shell
:return: The output of the command.
"""
# Execute the command and gather errors and output
_, stdout, stderr = self.connection.exec_command(command + "\n")
output = stdout.read().decode('utf-8')

if stderr:
# Error was present, add message to output
output += stderr.read().decode('utf-8')

# Close connection and return output
self.connection.close()
return output
44 changes: 44 additions & 0 deletions rcli/rexec.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import os
import click
import paramiko
import sys

from .linecard import Linecard
from rcli import utils as rcli_utils
from sonic_py_common import device_info

@click.command()
@click.argument('linecard_names', nargs=-1, type=str, required=True)
@click.option('-c', '--command', type=str, required=True)
def cli(linecard_names, command):
"""
Executes a command on one or many linecards
:param linecard_names: A list of linecard names to execute the command on,
use `all` to execute on all linecards.
:param command: The command to execute on the linecard(s)
"""
if not device_info.is_chassis():
click.echo("This commmand is only supported Chassis")
sys.exit(1)

username = os.getlogin()
password = rcli_utils.get_password(username)

if list(linecard_names) == ["all"]:
# Get all linecard names using autocompletion helper
linecard_names = rcli_utils.get_all_linecards(None, None, "")

# Iterate through each linecard, execute command, and gather output
for linecard_name in linecard_names:
try:
lc = Linecard(linecard_name, username, password)
if lc.connection:
# If connection was created, connection exists. Otherwise, user will see an error message.
click.echo("======== {} output: ========".format(lc.linecard_name))
click.echo(lc.execute_cmd(command))
except paramiko.ssh_exception.AuthenticationException:
click.echo("Login failed on '{}' with username '{}'".format(linecard_name, lc.username))

if __name__=="__main__":
cli(prog_name='rexec')
38 changes: 38 additions & 0 deletions rcli/rshell.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import os
import click
import paramiko
import sys

from .linecard import Linecard
from sonic_py_common import device_info
from rcli import utils as rcli_utils


@click.command()
@click.argument('linecard_name', type=str, autocompletion=rcli_utils.get_all_linecards)
def cli(linecard_name):
"""
Open interactive shell for one linecard
:param linecard_name: The name of the linecard to connect to
"""
if not device_info.is_chassis():
click.echo("This commmand is only supported Chassis")
sys.exit(1)

username = os.getlogin()
password = rcli_utils.get_password(username)

try:
lc =Linecard(linecard_name, username, password)
if lc.connection:
click.echo("Connecting to {}".format(lc.linecard_name))
# If connection was created, connection exists. Otherwise, user will see an error message.
lc.start_shell()
click.echo("Connection Closed")
except paramiko.ssh_exception.AuthenticationException:
click.echo("Login failed on '{}' with username '{}'".format(linecard_name, lc.username))


if __name__=="__main__":
cli(prog_name='rshell')
149 changes: 149 additions & 0 deletions rcli/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import click
from getpass import getpass
import os
import sys

from swsscommon.swsscommon import SonicV2Connector

CHASSIS_MODULE_INFO_TABLE = 'CHASSIS_MODULE_TABLE'
CHASSIS_MODULE_INFO_KEY_TEMPLATE = 'CHASSIS_MODULE {}'
CHASSIS_MODULE_INFO_DESC_FIELD = 'desc'
CHASSIS_MODULE_INFO_SLOT_FIELD = 'slot'
CHASSIS_MODULE_INFO_OPERSTATUS_FIELD = 'oper_status'
CHASSIS_MODULE_INFO_ADMINSTATUS_FIELD = 'admin_status'

CHASSIS_MIDPLANE_INFO_TABLE = 'CHASSIS_MIDPLANE_TABLE'
CHASSIS_MIDPLANE_INFO_IP_FIELD = 'ip_address'
CHASSIS_MIDPLANE_INFO_ACCESS_FIELD = 'access'

CHASSIS_MODULE_HOSTNAME_TABLE = 'CHASSIS_MODULE_HOSTNAME_TABLE'
CHASSIS_MODULE_HOSTNAME = 'module_hostname'

def connect_to_chassis_state_db():
chassis_state_db = SonicV2Connector(host="127.0.0.1")
chassis_state_db.connect(chassis_state_db.CHASSIS_STATE_DB)
return chassis_state_db


def connect_state_db():
state_db = SonicV2Connector(host="127.0.0.1")
state_db.connect(state_db.STATE_DB)
return state_db



def get_linecard_module_name_from_hostname(linecard_name: str):

chassis_state_db = connect_to_chassis_state_db()

keys = chassis_state_db.keys(chassis_state_db.CHASSIS_STATE_DB , '{}|{}'.format(CHASSIS_MODULE_HOSTNAME_TABLE, '*'))
for key in keys:
module_name = key.split('|')[1]
hostname = chassis_state_db.get(chassis_state_db.CHASSIS_STATE_DB, key, CHASSIS_MODULE_HOSTNAME)
if hostname.replace('-', '').lower() == linecard_name.replace('-', '').lower():
return module_name

return None

def get_linecard_ip(linecard_name: str):
"""
Given a linecard name, lookup its IP address in the midplane table
:param linecard_name: The name of the linecard you want to connect to
:type linecard_name: str
:return: IP address of the linecard
"""
# Adapted from `show chassis modules midplane-status` command logic:
# https://github.com/sonic-net/sonic-utilities/blob/master/show/chassis_modules.py

# if the user passes linecard hostname, then try to get the module name for that linecard
module_name = get_linecard_module_name_from_hostname(linecard_name)
# if the module name cannot be found from host, assume the user has passed module name
if module_name is None:
module_name = linecard_name
module_ip, module_access = get_module_ip_and_access_from_state_db(module_name)

if not module_ip:
click.echo('Linecard {} not found'.format(linecard_name))
return None

if module_access != 'True':
click.echo('Linecard {} not accessible'.format(linecard_name))
return None


return module_ip

def get_module_ip_and_access_from_state_db(module_name):
state_db = connect_state_db()
data_dict = state_db.get_all(
state_db.STATE_DB, '{}|{}'.format(CHASSIS_MIDPLANE_INFO_TABLE,module_name ))
if data_dict is None:
return None, None

linecard_ip = data_dict.get(CHASSIS_MIDPLANE_INFO_IP_FIELD, None)
access = data_dict.get(CHASSIS_MIDPLANE_INFO_ACCESS_FIELD, None)

return linecard_ip, access


def get_all_linecards(ctx, args, incomplete) -> list:
"""
Return a list of all accessible linecard names. This function is used to
autocomplete linecard names in the CLI.
:param ctx: The Click context object that is passed to the command function
:param args: The arguments passed to the Click command
:param incomplete: The string that the user has typed so far
:return: A list of all accessible linecard names.
"""
# Adapted from `show chassis modules midplane-status` command logic:
# https://github.com/sonic-net/sonic-utilities/blob/master/show/chassis_modules.py


chassis_state_db = connect_to_chassis_state_db()
state_db = connect_state_db()

linecards = []
keys = state_db.keys(state_db.STATE_DB,'{}|*'.format(CHASSIS_MIDPLANE_INFO_TABLE))
for key in keys:
key_list = key.split('|')
if len(key_list) != 2: # error data in DB, log it and ignore
click.echo('Warn: Invalid Key {} in {} table'.format(key, CHASSIS_MIDPLANE_INFO_TABLE ))
continue
module_name = key_list[1]
linecard_ip, access = get_module_ip_and_access_from_state_db(module_name)
if linecard_ip is None:
continue

if access != "True" :
continue

# get the hostname for this module
hostname = chassis_state_db.get(chassis_state_db.CHASSIS_STATE_DB, '{}|{}'.format(CHASSIS_MODULE_HOSTNAME_TABLE, module_name), CHASSIS_MODULE_HOSTNAME)
if hostname:
linecards.append(hostname)
else:
linecards.append(module_name)

# Return a list of all matched linecards
return [lc for lc in linecards if incomplete in lc]


def get_password(username=None):
"""
Prompts the user for a password, and returns the password
:param username: The username that we want to get the password for
:type username: str
:return: The password for the username.
"""

if username is None:
username =os.getlogin()

return getpass(
"Password for username '{}': ".format(username),
# Pass in click stdout stream - this is similar to using click.echo
stream=click.get_text_stream('stdout')
)
Loading

0 comments on commit b01a94e

Please sign in to comment.