Ravn::

Executor class

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 )
  #=> { response: :ok, status: 0, runtime: 229.3, output: "..." }
Ravn::Executor.exec( :not_listed )
  #=> { response: :unknown_command }

Attributes

args R

Optional arguments for the command.

command R

The command identifier.

command_sets R

The parsed list of possible commands.

Public Class Methods

exec( command, *arguments )

Execute a command with optional arguments.

# File lib/ravn/executor.rb, line 100
def self::exec( command, *arguments )
        return new( command, *arguments ).run( self.local_mode )
end
start_server()

Create a listening socket, and service incoming execution requests. This is intended to be run by root.

# File lib/ravn/executor.rb, line 107
def self::start_server
        self.log.warn "Starting server at: %s" % [ self.socket ]
        sock = CZTop::Socket::REP.new( self.socket )

        # Ensure non-root users can write to the socket when
        # it's a local pipe.
        if self.socket.start_with?( 'ipc' )
                sock_path = URI.parse( self.socket ).path
                Pathname( sock_path ).chmod( 0666 )
        end

        # Drop privileges.
        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

                # Force running locally for the server.
                response = new( command, *request ).run( true )
                sock << YAML.dump( response )
        end
end

Public Instance Methods

run( run_locally=true )

Route an execution command to an Executor server or locally in-process.

# File lib/ravn/executor.rb, line 163
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.

# File lib/ravn/executor.rb, line 145
def initialize( command, *arguments ) # :notnew:
        @command = command
        @args    = arguments
        @command_sets ||= YAML.load( self.class.commands_file.read )
end
prep_command()

Structure and populate the command template with supplied arguments. Returns nil if the command is invalid.

# File lib/ravn/executor.rb, line 231
def prep_command
        return unless command_set = self.command_sets[ self.command ].dup

        # Walk the arguments, substituting argument placeholders.
        #
        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
run_command_set()

Call a command set with optional args, returning a hash with execution results to the caller.

# File lib/ravn/executor.rb, line 182
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.

# File lib/ravn/executor.rb, line 256
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