Gurpartap/cognizant

View on GitHub
lib/cognizant/process/execution.rb

Summary

Maintainability
C
1 day
Test Coverage
require 'cognizant/util/transform_hash_keys'

module Cognizant
  class Process
    module Execution
      ExecutionResult = Struct.new(
        :pid,
        :stdout,
        :stderr,
        :exit_code,
        :succeeded
      ) do alias succeeded? succeeded end

      def execute(command, options = {})
        options[:groups] ||= []
        options[:env]    ||= {}

        pid, pid_w = IO.pipe

        unless options[:daemonize]
          # Stdout and stderr file descriptors.
          out_r, out_w = IO.pipe
          err_r, err_w = IO.pipe
        end

        # Run the following in a fork so that the privilege changes do not affect the parent process.
        fork_pid = ::Process.fork do
          # Set the user and groups for the process in context.
          drop_privileges(options)

          if options[:daemonize]
            # Create a new session to detach from the controlling terminal.
            unless ::Process.setsid
              Log[self].error "Cannot detach #{options[:name]} from controlling terminal"
            end

            # TODO: Set pgroup: true so that the spawned process is the group leader, and it's death would kill all children as well.

            # Prevent inheriting signals from parent process.
            setup_execution_traps

            # Give the process a name.
            $0 = options[:name] if options[:name]

            # Collect logs only when daemonizing.
            stdout = [options[:logfile] || "/dev/null", "a"]
            stderr = [options[:errfile] || options[:logfile] || "/dev/null", "a"]
          else
            stdout = out_w
            stderr = err_w
          end

          # TODO: Run popen as spawned process before privileges are dropped for increased abilities?
          stdin_data = options[:input] if options[:input]
          stdin_data = IO.popen(options[:input_command]).read if options[:input_command]

          if stdin_data
            stdin, stdin_w = IO.pipe
            stdin_w.write stdin_data
            stdin_w.close # stdin is closed by ::Process.spawn.
          elsif options[:input_file] and File.exists?(options[:input_file])
            stdin = options[:input_file]
          else
            stdin = "/dev/null"
          end

          # Merge spawn options.
          spawn_options = construct_spawn_options(options, {
            :in  => stdin,
            :out => stdout,
            :err => stderr
          })

          # Spawn a process to execute the command.
          process_pid = ::Process.spawn(options[:env].deep_stringify_keys!, command, spawn_options)
          # Log[self].debug "process_pid: #{process_pid} (#{command})"
          pid_w.write(process_pid)

          if options[:daemonize]
            # We do not care about actual status or output when daemonizing.
            exit(0)
          else
            # TODO: Timeout here, in case the process doesn't daemonize itself.

            # Wait (blocking) until the command has finished running.
            status = ::Process.waitpid2(process_pid)[1]

            # Pass on the exit status to the parent.
            exit(status.exitstatus || 0) # TODO: This 0 or something else should be controlled by timeout handler.
          end
        end

        # Close the pid file descriptor.
        pid_w.close

        if options[:daemonize]
          # Detach (non blocking) the process executing the command and move on.
          ::Process.detach(fork_pid)

          return ExecutionResult.new(
            pid.read.to_i,
            nil,
            nil,
            0,
            true
          )
        else
          # Wait until the fork has finished running and accept the exit status.
          status = ::Process.waitpid2(fork_pid)[1]

          # Timeout and try (detach + pid_running?)?

          # Close the file descriptors.
          out_w.close
          err_w.close

          # Collect and return stdout, stderr and exitcode.
          return ExecutionResult.new(
            nil,
            out_r.read,
            err_r.read,
            status.exitstatus,
            status.exitstatus ? status.exitstatus.zero? : false # TODO: What rare case would not have status.existatus?
          )
        end
      end

      private

      def drop_privileges(options = {})
        # Cannot drop privileges unless we are superuser.
        if ::Process.euid == 0
          # Drop ~= decrease, since we can only decrease privileges.

          # For clarity.
          uid    = options[:uid]
          gid    = options[:gid]
          groups = options[:groups]

          # Find the user and primary group in the password and group database.
          user  = (uid.is_a? Integer) ? Etc.getpwuid(uid) : Etc.getpwnam(uid) if uid
          group = (gid.is_a? Integer) ? Etc.getgruid(gid) : Etc.getgrnam(gid) if gid

          # Collect the secondary groups' GIDs.
          group_ids = groups.map { |g| Etc.getgrnam(g).gid } if groups

          # Set the fork's secondary user groups for the spawn process to inherit.
          ::Process.groups = [group.gid] if group # Including the primary group.
          ::Process.groups |= group_ids if groups and !group_ids.empty?

          # Set the fork's user and primary group for the spawn process to inherit.
          ::Process.uid = user.uid  if user
          ::Process.gid = group.gid if group

          # Find and set the user's HOME environment variable for fun.
          options[:env] = options[:env].merge({ 'HOME' => user.dir }) if user and user.dir

          # Changes the process' idea of the file system root.
          Dir.chroot(options[:chroot]) if options[:chroot]

          # umask and chdir drops are managed by ::Process.spawn.
        end
      end

      private

      def setup_execution_traps
        Signal.trap('TERM', 'SIG_DFL')
        Signal.trap('INT',  'SIG_DFL')
        Signal.trap('QUIT', 'SIG_DFL')
        Signal.trap('HUP',  'SIG_DFL')
      end

      def construct_spawn_options(options, overrides = {})
        spawn_options = {}
        [:chdir, :umask].each do |o|
          spawn_options[o] = options[o] if options[o]
        end
        spawn_options.merge(overrides)
      end
    end
  end
end