class Shells::ShellBase
Provides a base interface for all shells to build on.
Instantiating this class will raise an error. All shell sessions should inherit this class and override the necessary interface methods.
Attributes
Set to true to ignore IO errors.
Gets the exit code from the last command if it was retrieved.
Gets the last time output was received from the shell.
The options provided to this shell.
This hash is read-only.
Gets all of the output contents from the session.
used when the buffer is pushed/popped.
track exceptions raised during session execution.
the thread used to run the session.
Gets the STDERR contents from the session.
Gets the STDOUT contents from the session.
The character string we are expecting to be echoed back from the shell.
Public Class Methods
Initializes the shell with the supplied options.
These options are common to all shells.
prompt
-
Defaults to “~~#”. Most special characters will be stripped.
retrieve_exit_code
-
Defaults to false. Can also be true.
on_non_zero_exit_code
-
Defaults to :ignore. Can also be :raise.
silence_timeout
-
Defaults to 0. If greater than zero, will raise an error after waiting this many seconds for a prompt.
command_timeout
-
Defaults to 0. If greater than zero, will raise an error after a command runs for this long without finishing.
unbuffered_input
-
Defaults to false. If non-false, then input is sent one character at a time, otherwise input is sent in whole strings. If set to :echo, then input is sent one character at a time and the character must be echoed back from the shell before the next character will be sent.
Please check the documentation for each shell class for specific shell options.
# File lib/shells/shell_base/options.rb, line 53 def initialize(options = {}, &block) # cannot instantiate a ShellBase raise NotImplementedError if self.class == Shells::ShellBase raise ArgumentError, '\'options\' must be a hash.' unless options.is_a?(Hash) self.options = { prompt: '~~#', retrieve_exit_code: false, on_non_zero_exit_code: :ignore, silence_timeout: 0, command_timeout: 0, unbuffered_input: false }.merge( options.inject({}){ |m,(k,v)| m[k.to_sym] = v; m } ) self.options[:prompt] = self.options[:prompt] .to_s.strip .gsub('!', '#') .gsub('$', '#') .gsub('\\', '.') .gsub('/', '.') .gsub('"', '-') .gsub('\'', '-') self.options[:prompt] = '~~#' if self.options[:prompt] == '' raise Shells::InvalidOption, ':on_non_zero_exit_code must be :ignore or :raise.' unless [:ignore, :raise].include?(self.options[:on_non_zero_exit_code]) validate_options self.options.freeze # no more changes to options now. self.orig_options = self.options # sort of, we might provide helpers (like +change_quit+) run_hook :on_init # allow for backwards compatibility. if block_given? run &block end end
Sets the code to be run when debug messages are processed.
The code will receive the debug message as an argument.
on_debug do |msg| puts msg end
# File lib/shells/shell_base/debug.rb, line 13 def self.on_debug(proc = nil, &block) add_hook :on_debug, proc, &block end
Protected Class Methods
Adds a hook method to the class.
A hook method should return :break if it wants to cancel executing any other hook methods in the chain.
# File lib/shells/shell_base/hooks.rb, line 24 def self.add_hook(hook_name, proc = nil, &block) #:doc: hooks[hook_name] ||= [] if proc.respond_to?(:call) hooks[hook_name] << proc elsif proc.is_a?(Symbol) || proc.is_a?(String) if self.respond_to?(proc, true) hooks[hook_name] << method(proc.to_sym) end elsif proc raise ArgumentError, 'proc must respond to :call method or be the name of a static method in this class' end if block hooks[hook_name] << block end end
Processes a debug message.
# File lib/shells/shell_base/debug.rb, line 21 def self.debug(msg) #:doc: run_static_hook :on_debug, msg end
Adds code to be run when an exception occurs.
This code will receive the shell as the first argument and the exception as the second. If it handles the exception it should return :break.
on_exception do |shell, ex| if ex.is_a?(MyExceptionType) ... :break else false end end
You can also pass the name of a static method.
def self.some_exception_handler(shell, ex) ... end on_exception :some_exception_handler
# File lib/shells/shell_base/run.rb, line 182 def self.on_exception(proc = nil, &block) add_hook :on_exception, proc, &block end
Runs a hook statically.
The arguments supplied are passed to the hook methods directly.
Return false unless the hook was executed. Returns :break if one of the hook methods returns :break.
# File lib/shells/shell_base/hooks.rb, line 51 def self.run_static_hook(hook_name, *args) list = all_hooks(hook_name) list.each do |hook| result = hook.call(*args) return :break if result == :break end list.any? end
Private Class Methods
# File lib/shells/shell_base/hooks.rb, line 14 def self.all_hooks(name) parent_hooks(name) + (hooks[name] || []) end
# File lib/shells/shell_base/hooks.rb, line 6 def self.hooks @hooks ||= {} end
# File lib/shells/shell_base/hooks.rb, line 10 def self.parent_hooks(name) superclass.respond_to?(:all_hooks, true) ? superclass.send(:all_hooks, name) : [] end
Public Instance Methods
Allows you to change the :quit option inside of a session.
This is useful if you need to change the quit command for some reason. e.g. - Changing the command to “reboot”.
Returns the shell instance.
# File lib/shells/shell_base/options.rb, line 103 def change_quit(quit_command) raise Shells::NotRunning unless running? self.options = options.dup.merge( quit: quit_command ).freeze self end
Executes a command during the shell session.
If called outside of the new
block, this will raise an error.
The command
is the command to execute in the shell.
The options
can be used to override the exit code behavior. In all cases, the :default option is the same as not providing the option and will cause exec
to inherit the option from the shell's options.
retrieve_exit_code
-
This can be one of :default, true, or false.
on_non_zero_exit_code
-
This can be on ot :default, :ignore, or :raise.
silence_timeout
-
This can be :default or the number of seconds to wait in silence before timing out.
command_timeout
-
This can be :default or the maximum number of seconds to wait for a command to finish before timing out.
If provided, the block
is a chunk of code that will be processed every time the shell receives output from the program. If the block returns a string, the string will be sent to the shell. This can be used to monitor processes or monitor and interact with processes. The block
is optional.
shell.exec('sudo -p "password:" nginx restart') do |data,type| return 'super-secret' if /password:$/.match(data) nil end
# File lib/shells/shell_base/exec.rb, line 51 def exec(command, options = {}, &block) raise Shells::NotRunning unless running? options ||= {} options = { timeout_error: true, get_output: true }.merge(options) options = self.options.merge(options.inject({}) { |m,(k,v)| m[k.to_sym] = v; m }) options[:retrieve_exit_code] = self.options[:retrieve_exit_code] if options[:retrieve_exit_code] == :default options[:on_non_zero_exit_code] = self.options[:on_non_zero_exit_code] unless [:raise, :ignore].include?(options[:on_non_zero_exit_code]) options[:silence_timeout] = self.options[:silence_timeout] if options[:silence_timeout] == :default options[:command_timeout] = self.options[:command_timeout] if options[:command_timeout] == :default options[:command_is_echoed] = true if options[:command_is_echoed].nil? ret = '' merge_local_buffer do begin # buffer while also passing data to the supplied block. if block_given? buffer_output(&block) end command = command.to_s # send the command and wait for the prompt to return. debug 'Queueing command: ' + command queue_input command + line_ending if wait_for_prompt(options[:silence_timeout], options[:command_timeout], options[:timeout_error]) # get the output of the command, minus the trailing prompt. ret = if options[:get_output] debug 'Reading output of command...' command_output command, options[:command_is_echoed] else '' end if options[:retrieve_exit_code] self.last_exit_code = get_exit_code if options[:on_non_zero_exit_code] == :raise raise NonZeroExitCode.new(last_exit_code) unless last_exit_code == 0 || last_exit_code == :undefined end else self.last_exit_code = nil end else # A timeout occurred and timeout_error was set to false. self.last_exit_code = :timeout ret = output end ensure # return buffering to normal. if block_given? buffer_output end end end ret end
Executes a command specifically for the exit code.
Does not return the output of the command, only the exit code.
# File lib/shells/shell_base/exec.rb, line 116 def exec_for_code(command, options = {}, &block) options = (options || {}).merge(retrieve_exit_code: true, on_non_zero_exit_code: :ignore) exec command, options, &block last_exit_code end
Executes a command ignoring any exit code.
Returns the output of the command and does not even retrieve the exit code.
# File lib/shells/shell_base/exec.rb, line 126 def exec_ignore_code(command, options = {}, &block) options = (options || {}).merge(retrieve_exit_code: false, on_non_zero_exit_code: :ignore) exec command, options, &block end
# File lib/shells/shell_base.rb, line 13 def inspect "#<#{self.class}:0x#{object_id.to_s(16).rjust(12,'0')} #{options.reject{|k,v| k == :password}.inspect}>" end
Defines the line ending used to terminate commands sent to the shell.
The default is “n”. If you need “rn”, “r”, or some other value, simply override this function.
# File lib/shells/shell_base/input.rb, line 23 def line_ending "\n" end
Reads from a file on the device.
# File lib/shells/shell_base/interface.rb, line 135 def read_file(path) nil end
Runs a shell session.
The block provided will be run asynchronously with the shell.
Returns the shell instance.
# File lib/shells/shell_base/run.rb, line 55 def run(&block) sync do raise Shells::AlreadyRunning if running? self.run_flag = true end begin run_hook :on_before_run debug 'Connecting...' connect debug 'Starting output buffering...' buffer_output debug 'Starting session thread...' self.session_thread = Thread.start(self) do |sh| begin begin debug 'Executing setup...' sh.instance_eval { setup } debug 'Executing block...' block.call sh ensure debug 'Executing teardown...' sh.instance_eval { teardown } end rescue Shells::QuitNow # just exit the session. rescue =>e # if the exception is handled by the hook no further processing is required, otherwise we store the exception # to propagate it in the main thread. unless sh.run_hook(:on_exception, e) == :break sh.sync { sh.instance_eval { self.session_exception = e } } end end end # process the input buffer while the thread is alive and the shell is active. debug 'Entering IO loop...' io_loop do if active? begin if session_thread.status # not dead # process input from the session. unless wait_for_output inp = next_input if inp send_data inp self.wait_for_output = (options[:unbuffered_input] == :echo) end end # continue running the IO loop true elsif session_exception # propagate the exception. raise session_exception.class, session_exception.message, session_exception.backtrace else # the thread has exited, but no exception exists. # regardless, the IO loop should now exit. false end rescue IOError if ignore_io_error # we were (sort of) expecting the IO error, so just tell the IO loop to exit. false else raise end end else # the shell session is no longer active, tell the IO loop to exit. false end end rescue # when an error occurs, try to disconnect, but ignore any further errors. begin debug 'Disconnecting...' disconnect rescue # ignore end raise else # when no error occurs, try to disconnect and propagate any errors (unless we are ignoring IO errors). begin debug 'Disconnecting...' disconnect rescue IOError raise unless ignore_io_error end ensure # cleanup run_hook :on_after_run self.run_flag = false end self end
Is the shell currently running?
# File lib/shells/shell_base/run.rb, line 15 def running? run_flag end
Writes to a file on the device.
# File lib/shells/shell_base/interface.rb, line 141 def write_file(path, data) false end
Protected Instance Methods
Determines if the shell is currently active.
You must define this method in your subclass.
# File lib/shells/shell_base/interface.rb, line 65 def active? #:doc: raise ::NotImplementedError end
Sets the block to call when data is received.
If no block is provided, then the shell will simply log all output from the program. If a block is provided, it will be passed the data as it is received. If the block returns a string, then that string will be sent to the shell.
This method is called internally in the exec
method, but there may be legitimate use cases outside of that method as well.
# File lib/shells/shell_base/output.rb, line 122 def buffer_output(&block) #:doc: raise Shells::NotRunning unless running? block ||= Proc.new { } stdout_received do |data| self.last_output = Time.now append_stdout strip_ansi_escape(data), &block end stderr_received do |data| self.last_output = Time.now append_stderr strip_ansi_escape(data), &block end end
Gets the output from a command.
# File lib/shells/shell_base/exec.rb, line 135 def command_output(command, expect_command = true) #:doc: # get everything except for the ending prompt. ret = if (prompt_pos = (output =~ prompt_match)) output[0...prompt_pos] else output end if expect_command command_regex = command_match(command) # Go until we run out of data or we find one of the possible command starts. # Note that we EXPECT the command to the first line of the output from the command because we expect the # shell to echo it back to us. result_cmd,_,result_data = ret.partition("\n") until result_data.to_s.strip == '' || result_cmd.strip =~ command_regex result_cmd,_,result_data = result_data.partition("\n") end if result_cmd.nil? || !(result_cmd =~ command_regex) STDERR.puts "SHELL WARNING: Failed to match #{command_regex.inspect}." end result_data else ret end end
Connects to the shell.
You must define this method in your subclass.
# File lib/shells/shell_base/interface.rb, line 48 def connect #:doc: raise ::NotImplementedError end
Processes a debug message for an instance.
This is processed synchronously.
# File lib/shells/shell_base/debug.rb, line 29 def debug(msg) #:doc: if have_hook?(:on_debug) sync { self.class.debug msg } end end
Executes the code block with a local output buffer, discarding the local buffer upon completion.
# File lib/shells/shell_base/output.rb, line 148 def discard_local_buffer(&block) #:doc: push_buffer begin yield ensure pop_discard_buffer end end
Disconnects from the shell.
You must define this method in your subclass. This method will always be called, even if an exception occurs during the session.
# File lib/shells/shell_base/interface.rb, line 57 def disconnect #:doc: raise ::NotImplementedError end
Gets the exit code from the last command.
You must define this method in your subclass to utilize exit codes.
# File lib/shells/shell_base/interface.rb, line 127 def get_exit_code #:doc: self.last_exit_code = :undefined end
Returns true if there are any hooks to run.
# File lib/shells/shell_base/hooks.rb, line 78 def have_hook?(hook_name) self.class.all_hooks(hook_name).any? end
Runs the IO loop on the shell while the block returns true.
You must define this method in your subclass. It should block for as little time as necessary before yielding to the block.
# File lib/shells/shell_base/interface.rb, line 74 def io_loop(&block) #:doc: raise ::NotImplementedError end
Executes the code block with a local output buffer, merging the local buffer into the parent buffer upon completion.
# File lib/shells/shell_base/output.rb, line 137 def merge_local_buffer(&block) #:doc: push_buffer begin yield ensure pop_merge_buffer end end
Adds input to be sent to the shell.
# File lib/shells/shell_base/input.rb, line 31 def queue_input(data) #:doc: sync do if options[:unbuffered_input] data = data.chars input_fifo.push *data else input_fifo.push data end end end
Runs a hook in the current shell instance.
The hook method is passed the shell as the first argument then the arguments passed to this method.
Return false unless the hook was executed. Returns :break if one of the hook methods returns :break.
# File lib/shells/shell_base/hooks.rb, line 66 def run_hook(hook_name, *args) list = self.class.all_hooks(hook_name) shell = self list.each do |hook| result = hook.call(shell, *args) return :break if result == :break end list.any? end
Sends data to the shell.
You must define this method in your subclass.
It is important that this method not be called directly outside of the run
method. Use queue_input
to send data to the shell so that it can be handled in a synchronous manner.
# File lib/shells/shell_base/interface.rb, line 85 def send_data(data) #:doc: raise ::NotImplementedError end
Sets up the shell session.
This method is called after connecting the shell before the session block is run.
By default this method will wait for the prompt to appear in the output.
If you need to set the prompt, you would want to do it here.
# File lib/shells/shell_base/interface.rb, line 14 def setup #:doc: setup_prompt end
Sets up the prompt for the shell session.
By default this method will wait for the prompt to appear in the output.
If you need to set the prompt, you would want to do it here.
# File lib/shells/shell_base/interface.rb, line 24 def setup_prompt #:doc: wait_for_prompt 30, 30, true end
Register a callback to run when stderr data is received.
The block will be passed the data received.
You must define this method in your subclass and it should set a hook to be called when data is received.
def stderr_received @conn.on_stderr do |data| yield data end end
# File lib/shells/shell_base/interface.rb, line 119 def stderr_received(&block) #:doc: raise ::NotImplementedError end
Register a callback to run when stdout data is received.
The block will be passed the data received.
You must define this method in your subclass and it should set a hook to be called when data is received.
def stdout_received @conn.on_stdout do |data| yield data end end
# File lib/shells/shell_base/interface.rb, line 102 def stdout_received(&block) #:doc: raise ::NotImplementedError end
Synchronizes actions between shell threads.
# File lib/shells/shell_base/sync.rb, line 18 def sync(&block) thread_lock.synchronize &block end
Tears down the shell session.
This method is called after the session block is run before disconnecting the shell.
The default implementation simply sends the quit command to the shell and waits up to 1 second for a result.
This method will be called even if an exception is raised during the session.
# File lib/shells/shell_base/interface.rb, line 37 def teardown #:doc: unless options[:quit].to_s.strip == '' self.ignore_io_error = true exec_ignore_code options[:quit], command_timeout: 1, timeout_error: false end end
Sets the prompt to the value temporarily for execution of the code block.
# File lib/shells/shell_base/prompt.rb, line 128 def temporary_prompt(prompt) #:doc: raise Shells::NotRunning unless running? old_prompt = prompt_match begin self.prompt_match = prompt yield if block_given? ensure self.prompt_match = old_prompt end end
Validates the options provided to the class.
You should define this method in your subclass.
# File lib/shells/shell_base/options.rb, line 24 def validate_options #:doc: warn "The validate_options() method is not defined on the #{self.class} class." end
Waits for the prompt to appear at the end of the output.
Once the prompt appears, new input can be sent to the shell. This is automatically called in exec
so you would only need to call it directly if you were sending data manually to the shell.
This method is used internally in the exec
method, but there may be legitimate use cases outside of that method as well.
# File lib/shells/shell_base/prompt.rb, line 46 def wait_for_prompt(silence_timeout = nil, command_timeout = nil, timeout_error = true) #:doc: raise Shells::NotRunning unless running? silence_timeout ||= options[:silence_timeout] command_timeout ||= options[:command_timeout] # when did we send a NL and how many have we sent while waiting for output? nudged_at = nil nudge_count = 0 silence_timeout = silence_timeout.to_s.to_f unless silence_timeout.is_a?(Numeric) nudge_seconds = if silence_timeout > 0 (silence_timeout / 3.0) # we want to nudge twice before officially timing out. else 0 end # if there is a limit for the command timeout, then set the absolute timeout for the loop. command_timeout = command_timeout.to_s.to_f unless command_timeout.is_a?(Numeric) timeout = if command_timeout > 0 Time.now + command_timeout else nil end # loop until the output matches the prompt regex. # if something gets output async server side, the silence timeout will be handy in getting the shell to reappear. # a match while waiting for output is invalid, so by requiring that flag to be false this should work with # unbuffered input as well. until output =~ prompt_match && !wait_for_output # hint that we need to let another thread run. Thread.pass last_response = last_output # Do we need to nudge the shell? if nudge_seconds > 0 && (Time.now - last_response) > nudge_seconds nudge_count = (nudged_at.nil? || nudged_at < last_response) ? 1 : (nudge_count + 1) # Have we previously nudged the shell? if nudge_count > 2 # we timeout on the third nudge. raise Shells::SilenceTimeout if timeout_error debug ' > silence timeout' return false else nudged_at = Time.now queue_input line_ending # wait a bit longer... self.last_output = nudged_at end end # honor the absolute timeout. if timeout && Time.now > timeout raise Shells::CommandTimeout if timeout_error debug ' > command timeout' return false end end # make sure there is a newline before the prompt, just to keep everything clean. pos = (output =~ prompt_match) if output[pos - 1] != "\n" # no newline before prompt, fix that. self.output = output[0...pos] + "\n" + output[pos..-1] end # make sure there is a newline at the end of STDOUT content buffer. if stdout[-1] != "\n" # no newline at end, fix that. self.stdout += "\n" end true end
Private Instance Methods
# File lib/shells/shell_base/output.rb, line 94 def append_stderr(data, &block) data = reduce_newlines data sync do self.stderr += data self.output += data end if block_given? result = block.call(data, :stderr) if result && result.is_a?(String) queue_input(result + line_ending) end end end
# File lib/shells/shell_base/output.rb, line 70 def append_stdout(data, &block) # Combined output gets the prompts, # but stdout will be without prompts. data = reduce_newlines data for_stdout = if (pos = (data =~ prompt_match)) data[0...pos] else data end sync do self.stdout += for_stdout self.output += data self.wait_for_output = false end if block_given? result = block.call(for_stdout, :stdout) if result && result.is_a?(String) queue_input(result + line_ending) end end end
# File lib/shells/shell_base/exec.rb, line 167 def command_match(command) p = prompt_match.source[0...-7] # trim off [ \t]*$ c = regex_escape command /\A(?:#{p}\s*)?#{c}[ \t]*\z/ end
# File lib/shells/shell_base/input.rb, line 44 def next_input sync { input_fifo.shift } end
Pops the buffers and discards the captured output.
This method is used internally in the get_exit_code
method, but there may be legitimate use cases outside of that method as well.
# File lib/shells/shell_base/output.rb, line 204 def pop_discard_buffer raise Shells::NotRunning unless running? # a standard pop discarding current data and retrieving the history. debug 'Discarding buffer <<' sync do hist_stdout, hist_stderr, hist_output = (output_stack.pop || []) self.stdout = hist_stdout || '' self.stderr = hist_stderr || '' self.output = hist_output || '' end end
Pops the buffers and merges the captured output.
This method is called internally in the exec
method, but there may be legitimate use cases outside of that method as well.
# File lib/shells/shell_base/output.rb, line 181 def pop_merge_buffer raise Shells::NotRunning unless running? # almost a standard pop, however we want to merge history with current. debug 'Merging buffer <<' sync do hist_stdout, hist_stderr, hist_output = (output_stack.pop || []) if hist_stdout self.stdout = hist_stdout + stdout end if hist_stderr self.stderr = hist_stderr + stderr end if hist_output self.output = hist_output + output end end end
# File lib/shells/shell_base/prompt.rb, line 6 def prompt_match @prompt_match end
# File lib/shells/shell_base/prompt.rb, line 10 def prompt_match=(value) # allow for trailing spaces or tabs, but no other whitespace. @prompt_match = if value.nil? nil elsif value.is_a?(::Regexp) value else /#{regex_escape value.to_s}[ \t]*$/ end end
Pushes the buffers for output capture.
This method is called internally in the exec
method, but there may be legitimate use cases outside of that method as well.
# File lib/shells/shell_base/output.rb, line 164 def push_buffer raise Shells::NotRunning unless running? # push the buffer so we can get the output of a command. debug 'Pushing buffer >>' sync do output_stack.push [ stdout, stderr, output ] self.stdout = '' self.stderr = '' self.output = '' end end
# File lib/shells/shell_base/output.rb, line 66 def reduce_newlines(data) data.gsub("\r\n", "\n").gsub(" \r", "").gsub("\r", "") end
# File lib/shells/shell_base/regex_escape.rb, line 5 def regex_escape(text) text .gsub('\\', '\\\\') .gsub('[', '\\[') .gsub(']', '\\]') .gsub('(', '\\(') .gsub(')', '\\)') .gsub('.', '\\.') .gsub('*', '\\*') .gsub('+', '\\+') .gsub('?', '\\?') .gsub('{', '\\{') .gsub('}', '\\}') .gsub('$', '\\$') .gsub('^', '\\^') end
# File lib/shells/shell_base/output.rb, line 55 def strip_ansi_escape(data) data .gsub(/\e\[(\d+;?)*[ABCDEFGHfu]/, "\n") # any of the "set cursor position" CSI commands. .gsub(/\e\[=?(\d+;?)*[A-Za-z]/,'') # \e[#;#;#A or \e[=#;#;#A basically all the CSI commands except ... .gsub(/\e\[(\d+;"[^"]+";?)+p/, '') # \e[#;"A"p .gsub(/\e[NOc]./,'?') # any of the alternate character set commands. .gsub(/\e[P_\]^X][^\e\a]*(\a|(\e\\))/,'') # any string command .gsub(/[\x00\x08\x0B\x0C\x0E-\x1F]/, '') # any non-printable characters (notice \x0A (LF) and \x0D (CR) are left as is). .gsub("\t", ' ') # turn tabs into spaces. end