A utility class for decoupling system() calls within the host environment.
Clients can only execute from a known command set. The executor is responsible for properly running the command with optional client supplied arguments, gathering output, reaping, and returning status information.
A command set is a YAML description file with the following format:
---
:install_tarball:
:capture_stderr: false # default
:run_as_root: false # default
:command:
-
- tar
- -C
- '{VAR1}'
- -zxvf
- '{VAR2}'
-
- '{VAR3}/install.sh'
:set_date:
:run_as_root: true
:command:
- [ date, -s, '@{VAR1}' ]
:reboot:
:run_as_root: true
:command: reboot
The “command” can be single array (single command), or a sequence of commands. Placeholders are substituted inline, and output across multiple commands is concatenated.
Variable substitution (‘{VAR1}’) MUST be quoted, or the command will be ignored. (Unquoted is valid YAML, but it expands to a hash.)
Client usage:
Ravn::Executor.exec( :set_date, 1645659364 )
Ravn::Executor.exec( :not_listed )
- args R
Optional arguments for the command.
- command R
The command identifier.
- command_sets R
The parsed list of possible commands.
exec( command, *arguments )
Execute a command
with optional arguments
.
def self::exec( command, *arguments )
return new( command, *arguments ).run( self.local_mode )
end
Create a listening socket, and service incoming execution requests. This is intended to be run by root.
def self::start_server
self.log.warn "Starting server at: %s" % [ self.socket ]
sock = CZTop::Socket::REP.new( self.socket )
if self.socket.start_with?( 'ipc' )
sock_path = URI.parse( self.socket ).path
Pathname( sock_path ).chmod( 0666 )
end
effective_user = Etc.getpwnam( self.effective_user )
Process::Sys.seteuid( effective_user.uid )
loop do
self.log.info "Waiting for an incoming request..."
request = sock.receive.pop or break
command = nil
begin
request = YAML.load( request )
command = request.shift
rescue => err
self.log.error "Invalid message received: %p" % [ request ]
sock << YAML.dump( :response => :unparsable, message: err.message )
next
end
response = new( command, *request ).run( true )
sock << YAML.dump( response )
end
end
Route an execution command to an Executor
server or locally in-process.
def run( run_locally=true )
if run_locally
return self.run_command_set
else
sock = CZTop::Socket::REQ.new( self.class.socket )
sock << YAML.dump( [ self.command, *self.args ] )
rv = YAML.load( sock.receive.pop )
sock.close
return rv
end
end
Protected Instance Methods
initialize( command, *arguments )
Instantiante a command instance. An instance is responsible for validating the command
and expanding arguments
.
def initialize( command, *arguments )
@command = command
@args = arguments
@command_sets ||= YAML.load( self.class.commands_file.read )
end
Structure and populate the command template with supplied arguments. Returns nil
if the command is invalid.
def prep_command
return unless command_set = self.command_sets[ self.command ].dup
commands = Array( command_set[:command] ).map do |cmd|
Array( cmd ).map do |arg|
arg_match = arg.match( /{VAR(\d+)}/ )
arg.sub!( /{VAR\d+}/, self.args[ arg_match[1].to_i - 1 ].to_s ) if arg_match
arg
end
end
command_set[ :command ] = commands
return command_set
rescue NoMethodError => err
self.log.error err.message
return
end
Call a command
set with optional args
, returning a hash with execution results to the caller.
def run_command_set
command_set = self.prep_command or return {
:response => :unknown_command
}
output = ""
last_status = nil
start_time = Time.now
command_list = command_set[ :command ]
original_uid = Process::Sys.geteuid
Process::Sys.seteuid( 0 ) if command_set[ :run_as_root ]
command_list.each_with_index do |cmd, idx|
cmdstr = cmd.join( ' ' )
output << "# %s\n" % [ cmdstr ]
self.log.debug "Running %p as uid %d" % [ cmdstr, Process::Sys.geteuid ]
last_status, last_output = self.spawn(
*cmd,
redirect_stderr: command_set[:capture_stderr]
)
output << last_output << "\n"
if command_list.size > idx + 1 && ! last_status.to_i.zero?
self.log.warn "Exec (%p) failed, aborting %p" % [
cmdstr,
command
]
break
end
end
return {
response: last_status.zero? ? :ok : :error,
status: last_status,
runtime: Time.now - start_time,
output: output
}
ensure
if command_set && command_set[ :run_as_root ]
Process::Sys.seteuid( original_uid )
end
end
spawn( *cmd, redirect_stderr: false )
Safely call an external command and capture its output, optionally capturing stderr. Returns the command exit status and output as an array.
def spawn( *cmd, redirect_stderr: false )
cmd = Array( cmd ).flatten
in_pipe, out_pipe = IO.pipe
options = { out: out_pipe, close_others: true }
options[ :err ] = [ :child, :out ] if redirect_stderr
output = ""
pid = Process.spawn( *cmd, **options )
out_pipe.close
ready, _, _ = select( [ in_pipe ] )
while ready && ! ready.empty?
fd = ready.first
break if fd.eof? || fd.closed?
output << fd.read
ready, _, _ = select( [ in_pipe ] )
end
pid, status = Process.waitpid2( pid )
status = status.to_i / 256
return [ status, output ]
rescue => err
return [ -1, err.message ]
ensure
in_pipe.close if in_pipe && ! in_pipe.closed?
end