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, split_stderr: false, encoding: 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



215
216
217
# File 'lib/command_runner.rb', line 215

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

.ensure_command_output_encoded(string, command, encoding = nil) ⇒ Object

Forces encoding in the specified character set Defaults to no change (outputs in original encoding)

Raises:

  • (EncodingError)


178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
# File 'lib/command_runner.rb', line 178

def self.ensure_command_output_encoded(string, command, encoding = nil)
  return '' if !string

  return string if encoding.nil? ||  encoding != :safe

  encodingName = 'UTF-8'

  firstPass = string.force_encoding(encodingName)

  return firstPass if firstPass.valid_encoding?

  encoded = firstPass.encode(encodingName, encodingName,
                        invalid: :replace,
                        undef: :replace,
                        replace: "")

  return encoded if encoded.valid_encoding?

  raise EncodingError, %Q{
    Could not force #{encodingName} encoding on this string:
    #{string}
    which is the output of this command:
    #{command}
  }
end

.run(*args, timeout: nil, environment: {}, debug_log: nil, split_stderr: false, encoding: 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"})

For simple case of splitting stderr into a buffer separate from stdout you can pass the argument split_stderr: true. This will make an :err entry available in the result.

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).



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
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
# File 'lib/command_runner.rb', line 58

def self.run(*args, timeout: nil, environment: {}, debug_log: nil, split_stderr: false, encoding: 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

  if split_stderr
    err_r, err_w = IO.pipe
    errbuf = ""
    options = options.merge({:err => err_w})
  end

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

  err_w.close if split_stderr

  # 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)
        read_nonblock_safe!(err_r, errbuf, 0) if split_stderr
        result = {:out => ensure_command_output_encoded(data, args.join(" "), encoding), :status => $?, pid: io.pid}
        if split_stderr
          result[:err] = ensure_command_output_encoded(errbuf, args.join(" "), encoding)
          err_r.close
        end
        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)
        read_nonblock_safe!(err_r, errbuf, 0) if split_stderr
      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.
  loop do
    dead = read_nonblock_safe!(io, data, tick)
    read_nonblock_safe!(err_r, errbuf, 0) if split_stderr
    break if dead
  end
  Process.wait(io.pid)

  result = {:out => ensure_command_output_encoded(data, args.join(" "), encoding), :status => $?, pid: io.pid}
  if split_stderr
    result[:err] = ensure_command_output_encoded(errbuf, args.join(" "), encoding)
    err_r.close
  end

  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.



221
222
223
224
225
226
227
# File 'lib/command_runner.rb', line 221

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