Module: CommandRunner

Defined in:
lib/command_runner.rb

Defined Under Namespace

Classes: CommandInstance

Constant Summary collapse

MAX_TIME =
Time.new(2**63 - 1)
DEFAULT_OPTIONS =
{:err=>[:child, :out]}

Class Method Summary collapse

Class Method Details

.create(*args, timeout: nil, environment: {}, allowed_sub_commands: [], debug_log: nil, options: DEFAULT_OPTIONS) ⇒ Object

Create a helper instance to launch a command with a given configuration. Invoke the command with the run() method. The configuration given to create() can be overriden on each invocation of run().

The run() method of the helper instance must be invoked with a

Examples: git = CommandRunner.create([‘sudo’, ‘git’], timeout: 10, allowed_sub_commands: [:commit, :pull, :push]) git.run(:pull, ‘origin’, ‘master’) git.run(:pull, ‘origin’, ‘master’, timeout: 2) # override default timeout of 10 git.run(:status) # will raise an error because :status is not in list of allowed commands



160
161
162
# File 'lib/command_runner.rb', line 160

def self.create(*args, timeout: nil, environment: {}, allowed_sub_commands: [], debug_log: nil, options: DEFAULT_OPTIONS)
  CommandInstance.new(args, timeout, environment, allowed_sub_commands, debug_log, options)
end

.run(*args, timeout: nil, environment: {}, debug_log: nil, options: DEFAULT_OPTIONS) ⇒ Object

Like IO.popen(), but block until the child completes.

For convenience allows you to pass > 1 string args without a boxing array, to execute a command with arguments without a subshell. See examples below.

Takes an optional timeout parameter. If timeout is a number the child will be killed after that many seconds if it haven’t completed. Alternatively it can be a Hash of timeouts to actions. Each action can be a string or integer specifying the signal to send, or a Proc to execute. The Proc will be called with the child PID as argument.

These examples are equivalent:

run('sleep 10', timeout: 5) # With a subshell
run('sleep', '10', timeout: 5) # Without subshell. Convenience API to avoid array boxing as below
run(['sleep', '10'], timeout: 5) # No subshell in this one and the rest
run(['sleep', '10'], timeout: {5 => 'KILL'})
run(['sleep', '10'], timeout: {5 => Proc.new { |pid| Process.kill('KILL', pid)}})
run(['sleep', '10'], timeout: {
          5 => 'KILL',
          2 => Proc.new {|pid| puts "PID #{pid} geting SIGKILL in 3s"}
})

Takes an optional environment parameter (a Hash). The environment is populated with the keys/values of this parameter.

Returns a Hash with :out, :pid, and :status. :out is a string with stdout and stderr merged, and :status is a Process::Status. The :pid field is the the PID the child process had when it ran.

As a special case - if an action Proc raises an exception, the child will be killed with SIGKILL, cleaned up, and the exception rethrown to the caller of run.

By default stderr in the child is merged into its stdout. You can do any kind of advanced stream mapping by overriding the default options hash. The options are passed to Kernel.spawn. See ruby-doc.org/core-2.2.3/Kernel.html#method-i-spawn for details.

Fx. redirecting stderr to /dev/null would look like:

run('ls', 'nosuchfile', options: {:err => "/dev/null"})

All Kernel.spawn features, like setting umasks, process group, and are supported through the options hash.

Debugging: To help debugging your app you can set the debug_log parameter. It can be any old object responding to :puts. Fx. $stderr, $stdout, or the write end of an IO.pipe. CommandRunnerNG will put some info about all process start, stop, and timeouts here. To enable debug logging for all commands call CommandRunner.set_debug_log!($stderr) (or with some other object responding to :puts).



55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
# File 'lib/command_runner.rb', line 55

def self.run(*args, timeout: nil, environment: {}, debug_log: nil, options: DEFAULT_OPTIONS)
  if debug_log.nil?
    debug_log = @@global_debug_log
  end

  # If args is an array of strings, allow that as a shorthand for [arg1, arg2, arg3]
  if args.length > 1 && args.all? {|arg| arg.is_a? String}
    args = [args]
  end

  # This could be tweakable through vararg opts
  tick = 0.1

  now = Time.now

  # Build deadline_sequence. A list of deadlines and corresponding actions to take
  if timeout
    if timeout.is_a? Numeric
      deadline_sequence = [{:deadline => now + timeout, :action => 'KILL'}]
    elsif timeout.is_a? Hash
      deadline_sequence = timeout.collect do |t, action|
        unless action.is_a? Integer or action.is_a? String or action.is_a? Proc
            raise "Unsupported action type '#{action.class}'. Must be Integer, String, or Proc"
        end
        unless t.is_a? Numeric
          raise "Unsupported timeout value '#{t}'. Must be a Numeric"
        end
        {:deadline => now + t, :action => action}
      end.sort! { |a, b| a[:deadline] <=> b[:deadline]}
    else
      raise "Unsupported type for timeout paramter: #{timeout.class}"
    end
  else
    deadline_sequence = [{:deadline => MAX_TIME, :action => 0}]
  end

  # Spawn child, merging stderr into stdout
  io = IO.popen(environment, *args, options)
  debug_log.puts("CommandRunnerNG spawn: args=#{args}, timeout=#{timeout}, options: #{options}, PID: #{io.pid}") if debug_log.respond_to?(:puts)
  data = ""

  # Run through all deadlines until command completes.
  # We could merge this block into the selecting block above,
  # but splitting like this saves us a Process.wait syscall per iteration.
  eof = false
  deadline_sequence.each do |point|
    while Time.now < point[:deadline]
      if Process.wait(io.pid, Process::WNOHANG)
        read_nonblock_safe!(io, data, tick)
        result = {:out => data, :status => $?, pid: io.pid}
        debug_log.puts("CommandRunnerNG exit: PID: #{io.pid}, code: #{result[:status].exitstatus}") if debug_log.respond_to?(:puts)
        io.close
        return result
      elsif !eof
        eof = read_nonblock_safe!(io, data, tick)
      end
    end

    # Deadline for this point reached. Fire the action.
    action = point[:action]
    debug_log.puts("CommandRunnerNG timeout: PID: #{io.pid}, action: #{action}") if debug_log.respond_to?(:puts)
    if action.is_a? String or action.is_a? Integer
      Process.kill(action, io.pid)
    elsif action.is_a? Proc
      begin
        action.call(io.pid)
      rescue => e
        # If the action block throws and error, clean up and rethrow
        begin
          Process.kill('KILL', io.pid)
        rescue
          # process already dead
        end
        Process.wait(io.pid)
        io.close
        raise e
      end
    else
      # Given the assertions when building the deadline_sequence this should never be reached
      raise "Internal error in CommandRunnerNG. Child may be left unattended!"
    end
  end

  # Either we didn't have a deadline, or none of the deadlines killed off the child.
  Process.wait(io.pid)
  read_nonblock_safe!(io, data, tick)
  result = {:out => data, :status => $?, pid: io.pid}
  debug_log.puts("CommandRunnerNG exit: PID: #{io.pid}, code: #{result[:status].exitstatus}") if debug_log.respond_to?(:puts)

  io.close

  result
end

.set_debug_log!(logger) ⇒ Object

Log all command line invocations to a logger object responding to :puts. Set to nil to disable. Setting the :debug_log keyword argument on individual invocations of CommandRunner.run() overrides this value.



166
167
168
169
170
171
172
# File 'lib/command_runner.rb', line 166

def self.set_debug_log!(logger)
  if logger.respond_to?(:puts) || logger.nil?
    @@global_debug_log = logger
  else
    raise ArgumentError.new("Logger '#{logger}' not responding to :puts")
  end
end