ytti/oxidized

View on GitHub
examples/device-simulation/device2yaml.rb

Summary

Maintainability
A
2 hrs
Test Coverage
#!/usr/bin/env ruby
# frozen_string_literal: true

require 'net/ssh'
require 'optparse'
require 'etc'
require 'timeout'

# This scripts logs in a network device and outputs a yaml file that can be
# used for model unit tests in spec/model/

# This script is quick & dirty - it grew with the time an could be a project
# for its own. It works, and that should be enough ;-)

################# Methods
# Runs cmd in the ssh session, either im exec mode or with a tty
# saves the output to @output
def ssh_exec(cmd)
  puts "\n### Sending #{cmd}..."
  @output&.puts "  #{cmd}: |-"

  if @exec_mode
    @ssh_output = @ssh.exec! cmd + "\n"
  else
    @ses.send_data cmd + "\n"
    shell_wait
  end
  yaml_output('    ')
end

# Wait for the ssh command to be executed, with an idle timout @idle_timeout
# Pressing CTRL-C exits the script
# Pressing ESC termiates the idle timeout
def shell_wait
  @ssh_output = ''
  # ssh_output gets appended by chanel.on-data (below)
  # We store the current length of @ssh_output in @ssh_output_length
  # if @ssh_output.length is bigger than @ssh_output_length, we got new data
  @ssh_output_length = 0

  # Keep track of time for idle timeout
  start_time = Time.now

  # Loop & wait for @idle_timeout seconds after last output
  # 0.1 means that the loop should run at least once per 0.1 second
  @ssh.loop(0.1) do
    # if @ssh_output is longer than our saved length, we got new output
    if @ssh_output_length < @ssh_output.length
      # reset the timer and save the new output length
      start_time = Time.now
      @ssh_output_length = @ssh_output.length
    end

    # We wait for 0.1 seconds if a key was pressed
    begin
      Timeout.timeout(0.1) do
        # Get input // this is a blocking call
        char = $stdin.getch
        # If ctrl-c is pressed, exit the script
        if char == "\u0003"
          puts '### CTRL-C pressed, exiting'
          cleanup
          exit
        end
        # If escape is pressed, terminate idle timeout
        if char == "\e"
          puts "\n### ESC pressed, skipping idle timeout"
          return false
        else
          # if not, send the char through ssh
          @ses.send_data char
        end
      end
    rescue Timeout::Error
      # No key pressed
    end

    # exit the loop when the @idle_timeout has been reached (false = exit)
    Time.now - start_time < @idle_timeout
  end
end

def yaml_output(prepend = '')
  # Now print the collected output to @output
  # as we want to prepend 'prepend' to each line, we need each_line and chomp
  # chomp removes the trainling \n
  @ssh_output.each_line(chomp: true) do |line|
    # encode line and remove the first and the trailing double quote
    line = line.dump[1..-2]
    # Make sure leading spaces are coded with \0x20 or YAML won't work
    line.gsub!(/^ /, '\x20')
    # Make sure trailing white spaces are coded with \0x20
    line.gsub!(/ $/, '\x20')
    # prepend white spaces for the yaml block scalar
    line = prepend + line
    @output&.puts line
  end
end

def cleanup
  (@ssh.close rescue true) unless @ssh.closed?
  @output&.close
end

################# Main loop

# Define options
options = {}
optparse = OptionParser.new do |opts|
  opts.banner = "Usage: device2yaml.rb [user@]host [options]"

  opts.on('-c', '--cmdset file', 'Mandatory: specify the commands to be run') do |file|
    options[:cmdset] = file
  end
  opts.on('-o', '--output file', 'Specify an output YAML-file') do |file|
    options[:output] = file
  end
  opts.on('-t', '--timeout value', Integer, 'Specify the idle timeout beween commands (default: 5 seconds)') do |timeout|
    options[:timeout] = timeout
  end
  opts.on('-e', '--exec-mode', 'Run ssh in exec mode (without tty)') { @exec_mode = true }
  opts.on '-h', '--help', 'Print this help' do
    puts opts
    exit
  end
end

# Catch and parse the first argument
if ARGV[0] && ARGV[0][0] != '-'
  argument = ARGV.shift
  if argument.include?('@')
    ssh_user, ssh_host = argument.split('@')
  else
    ssh_user = Etc.getlogin
    ssh_host = argument
  end
else
  puts 'Missing a host to connect to...'
  puts
  puts optparse
  exit 1
end

# Parse the options
optparse.parse!

# Get the commands to be run against ssh_host
unless options[:cmdset]
  puts 'Missing a command set, use option -c'
  puts
  puts optparse
  exit 1
end
# make an array of commands to send, ignore empty lines
ssh_commands = File.read(options[:cmdset]).split(/\n+|\r+/)

# Defaut idle timeout: 5 seconds, as tests showed that 2 seconds is too short
@idle_timeout = options[:timeout] || 5

# We will use safe navifation (&.) to call the methods on @output only
# if @output is not nil
@output = options[:output] ? File.open(options[:output], 'w') : nil

@ssh = Net::SSH.start(ssh_host,
                      ssh_user,
                      { timeout:                         10,
                        append_all_supported_algorithms: true })

@ssh_output = ''

unless @exec_mode
  @ses = @ssh.open_channel do |ch|
    ch.on_data do |_ch, data|
      @ssh_output += data
      # Output the data to stdout for interactive control
      # remove ANSI escape codes, as they can produce problems
      # The code will be printed as '\e[123m' in the output
      print data.gsub("\e", '\e')
    end
    ch.request_pty(term: 'vt100') do |_ch, success_pty|
      raise NoShell, "Can't get PTY" unless success_pty

      ch.send_channel_request 'shell' do |_ch, success_shell|
        raise NoShell, "Can't get shell" unless success_shell
      end
    end
    ch.on_extended_data do |_ch, _type, data|
      $stderr.print "Error: #{data}\n"
    end
  end
end

# YAML begin of file
@output&.puts '---'

if @exec_mode
  # init prompt does not exist and is empty in exec mode
  @output&.puts 'init_prompt:'
else
  # get motd and first prompt
  @output&.puts 'init_prompt: |-'
  shell_wait
  yaml_output '  '
end

@output&.puts "commands:"

begin
  ssh_commands.each do |cmd|
    ssh_exec cmd
  end
rescue Errno::ECONNRESET, Net::SSH::Disconnect, IOError => e
  puts "### Connection closed with message: #{e.message}"
end

@output&.puts 'oxidized_output: |'
@output&.puts '  !! needs to be written by hand or copy & paste from model output'

cleanup