resque/resque

View on GitHub
lib/resque/web_runner.rb

Summary

Maintainability
C
1 day
Test Coverage
require 'open-uri'
require 'logger'
require 'optparse'
require 'fileutils'
require 'rack'
require 'resque/server'

# only used with `bin/resque-web`
# https://github.com/resque/resque/pull/1780

module Resque
  WINDOWS = !!(RUBY_PLATFORM =~ /(mingw|bccwin|wince|mswin32)/i)
  JRUBY = !!(RbConfig::CONFIG["RUBY_INSTALL_NAME"] =~ /^jruby/i)

  class WebRunner
    attr_reader :app, :app_name, :filesystem_friendly_app_name,
      :rack_handler, :port, :options, :args

    PORT       = 5678
    HOST       = WINDOWS ? 'localhost' : '0.0.0.0'

    def initialize(*runtime_args)
      @options = runtime_args.last.is_a?(Hash) ? runtime_args.pop : {}

      self.class.logger.level = options[:debug] ? Logger::DEBUG : Logger::INFO

      @app      = Resque::Server
      @app_name = 'resque-web'
      @filesystem_friendly_app_name = @app_name.gsub(/\W+/, "_")

      @args = load_options(runtime_args)

      @rack_handler = (s = options[:rack_handler]) ? Rack::Handler.get(s) : setup_rack_handler

      case option_parser.command
      when :help
        puts option_parser
      when :kill
        kill!
      when :status
        status
      when :version
        puts "resque #{Resque::VERSION}"
        puts "rack #{Rack::VERSION.join('.')}"
        puts "sinatra #{Sinatra::VERSION}" if defined?(Sinatra)
      else
        before_run
        start unless options[:start] == false
      end
    end

    def launch_path
      if options[:launch_path].respond_to?(:call)
        options[:launch_path].call(self)
      else
        options[:launch_path]
      end
    end

    def app_dir
      if !options[:app_dir] && !ENV['HOME']
        raise ArgumentError.new("nor --app-dir neither ENV['HOME'] defined")
      end
      options[:app_dir] || File.join(ENV['HOME'], filesystem_friendly_app_name)
    end

    def pid_file
      options[:pid_file] || File.join(app_dir, "#{filesystem_friendly_app_name}.pid")
    end

    def url_file
      options[:url_file] || File.join(app_dir, "#{filesystem_friendly_app_name}.url")
    end

    def log_file
      options[:log_file] || File.join(app_dir, "#{filesystem_friendly_app_name}.log")
    end

    def host
      options.fetch(:host) { HOST }
    end

    def url
      "http://#{host}:#{port}"
    end

    def before_run
      if (redis_conf = options[:redis_conf])
        logger.info "Using Redis connection '#{redis_conf}'"
        Resque.redis = redis_conf
      end
      if (namespace = options[:redis_namespace])
        logger.info "Using Redis namespace '#{namespace}'"
        Resque.redis.namespace = namespace
      end
      if (url_prefix = options[:url_prefix])
        logger.info "Using URL Prefix '#{url_prefix}'"
        Resque::Server.url_prefix = url_prefix
      end
      app.set(options.merge web_runner: self)
      path = (ENV['RESQUECONFIG'] || args.first)
      load_config_file(path.to_s.strip) if path
    end

    def start(path = launch_path)
      logger.info "Running with Windows Settings" if WINDOWS
      logger.info "Running with JRuby" if JRUBY
      logger.info "Starting '#{app_name}'..."

      check_for_running(path)
      find_port
      write_url
      launch!(url, path)
      daemonize! unless options[:foreground]
      run!
    rescue RuntimeError => e
      logger.warn "There was an error starting '#{app_name}': #{e}"
      exit
    end

    def find_port
      if @port = options[:port]
        announce_port_attempted

        unless port_open?
          logger.warn "Port #{port} is already in use. Please try another. " +
                      "You can also omit the port flag, and we'll find one for you."
        end
      else
        @port = PORT
        announce_port_attempted

        until port_open?
          @port += 1
          announce_port_attempted
        end
      end
    end

    def announce_port_attempted
      logger.info "trying port #{port}..."
    end

    def port_open?(check_url = nil)
      begin
        check_url ||= url
        options[:no_proxy] ? uri_open(check_url, :proxy => nil) : uri_open(check_url)
        false
      rescue Errno::ECONNREFUSED, Errno::EPERM, Errno::ETIMEDOUT
        true
      end
    end

    def uri_open(*args)
      (RbConfig::CONFIG['ruby_version'] < '2.7') ? open(*args) : URI.open(*args)
    end

    def write_url
      # Make sure app dir is setup
      FileUtils.mkdir_p(app_dir)
      File.open(url_file, 'w') {|f| f << url }
    end

    def check_for_running(path = nil)
      if File.exist?(pid_file) && File.exist?(url_file)
        running_url = File.read(url_file)
        if !port_open?(running_url)
          logger.warn "'#{app_name}' is already running at #{running_url}"
          launch!(running_url, path)
          exit!(1)
        end
      end
    end

    def run!
      logger.info "Running with Rack handler: #{@rack_handler.inspect}"

      rack_handler.run app, :Host => host, :Port => port do |server|
        kill_commands.each do |command|
          trap(command) do
            ## Use thins' hard #stop! if available, otherwise just #stop
            server.respond_to?(:stop!) ? server.stop! : server.stop
            logger.info "'#{app_name}' received INT ... stopping"
            delete_pid!
          end
        end
      end
    end

    # Adapted from Rackup
    def daemonize!
      if JRUBY
        # It's not a true daemon but when executed with & works like one
        thread = Thread.new {daemon_execute}
        thread.join

      elsif RUBY_VERSION < "1.9"
        logger.debug "Parent Process: #{Process.pid}"
        exit!(0) if fork
        logger.debug "Child Process: #{Process.pid}"
        daemon_execute

      else
        Process.daemon(true, true)
        daemon_execute
      end
    end

    def daemon_execute
      File.umask 0000
      FileUtils.touch log_file
      STDIN.reopen    log_file
      STDOUT.reopen   log_file, "a"
      STDERR.reopen   log_file, "a"

      logger.debug "Child Process: #{Process.pid}"

      File.open(pid_file, 'w') {|f| f.write("#{Process.pid}") }
      at_exit { delete_pid! }
    end

    def launch!(specific_url = nil, path = nil)
      return if options[:skip_launch]
      cmd = WINDOWS ? "start" : "open"
      system "#{cmd} #{specific_url || url}#{path}"
    end

    def kill!
      pid = File.read(pid_file)
      logger.warn "Sending #{kill_command} to #{pid.to_i}"
      Process.kill(kill_command, pid.to_i)
    rescue => e
      logger.warn "pid not found at #{pid_file} : #{e}"
    end

    def status
      if File.exist?(pid_file)
        logger.info "'#{app_name}' running"
        logger.info "PID #{File.read(pid_file)}"
        logger.info "URL #{File.read(url_file)}" if File.exist?(url_file)
      else
        logger.info "'#{app_name}' not running!"
      end
    end

    # Loads a config file at config_path and evals it in the context of the @app.
    def load_config_file(config_path)
      abort "Can not find config file at #{config_path}" if !File.readable?(config_path)
      config = File.read(config_path)
      # trim off anything after __END__
      config.sub!(/^__END__\n.*/, '')
      @app.module_eval(config)
    end

    def self.logger=(logger)
      @logger = logger
    end

    def self.logger
      @logger ||= LOGGER if defined?(LOGGER)
      if !@logger
        @logger           = Logger.new(STDOUT)
        @logger.formatter = Proc.new {|s, t, n, msg| "[#{t}] #{msg}\n"}
        @logger
      end
      @logger
    end

    def logger
      self.class.logger
    end

  private
    def setup_rack_handler
      # First try to set Rack handler via a special hook we honor
      @rack_handler = if @app.respond_to?(:detect_rack_handler)
        @app.detect_rack_handler

      # If they aren't using our hook, try to use their @app.server settings
      elsif @app.respond_to?(:server) and @app.server
        # If :server isn't set, it returns an array of possibilities,
        # sorted from most to least preferable.
        if @app.server.is_a?(Array)
          handler = nil
          @app.server.each do |server|
            begin
              handler = Rack::Handler.get(server)
              break
            rescue LoadError, NameError
              next
            end
          end
          raise 'No available Rack handler (e.g. WEBrick, Thin, Puma, etc.) was found.' if handler.nil?

          handler

        # :server might be set explicitly to a single option like "mongrel"
        else
          Rack::Handler.get(@app.server)
        end

      # If all else fails, we'll use Thin
      else
        JRUBY ? Rack::Handler::WEBrick : Rack::Handler::Thin
      end
    end

    def load_options(runtime_args)
      @args = option_parser.parse!(runtime_args)
      options.merge!(option_parser.options)
      args
    rescue OptionParser::MissingArgument => e
      logger.warn "#{e}, run -h for options"
      exit
    end

    def option_parser
      @option_parser ||= Parser.new(app_name)
    end

    class Parser < OptionParser
      attr_reader :command, :options

      def initialize(app_name)
        super("", 24, '  ')
        self.banner = "Usage: #{app_name} [options]"

        @options = {}
        basename = app_name.gsub(/\W+/, "_")
        on('-K', "--kill", "kill the running process and exit") { @command = :kill }
        on('-S', "--status", "display the current running PID and URL then quit") { @command = :status }
        string_option("-s", "--server SERVER", "serve using SERVER (thin/mongrel/webrick)", :rack_handler)
        string_option("-o", "--host HOST", "listen on HOST (default: #{HOST})", :host)
        string_option("-p", "--port PORT", "use PORT (default: #{PORT})", :port)
        on("-x", "--no-proxy", "ignore env proxy settings (e.g. http_proxy)") { opts[:no_proxy] = true }
        boolean_option("-F", "--foreground", "don't daemonize, run in the foreground", :foreground)
        boolean_option("-L", "--no-launch", "don't launch the browser", :skip_launch)
        boolean_option('-d', "--debug", "raise the log level to :debug (default: :info)", :debug)
        string_option("--app-dir APP_DIR", "set the app dir where files are stored (default: ~/#{basename}/)", :app_dir)
        string_option("-P", "--pid-file PID_FILE", "set the path to the pid file (default: app_dir/#{basename}.pid)", :pid_file)
        string_option("--log-file LOG_FILE", "set the path to the log file (default: app_dir/#{basename}.log)", :log_file)
        string_option("--url-file URL_FILE", "set the path to the URL file (default: app_dir/#{basename}.url)", :url_file)
        string_option('-N NAMESPACE', "--namespace NAMESPACE", "set the Redis namespace", :redis_namespace)
        string_option('-r redis-connection', "--redis redis-connection", "set the Redis connection string", :redis_conf)
        string_option('-a url-prefix', "--append url-prefix", "set reverse_proxy friendly prefix to links", :url_prefix)
        separator ""
        separator "Common options:"
        on_tail("-h", "--help", "Show this message") { @command = :help }
        on_tail("--version", "Show version") { @command = :version }
      end

      def boolean_option(*argv)
        k = argv.pop; on(*argv) { options[k] = true }
      end

      def string_option(*argv)
        k = argv.pop; on(*argv) { |value| options[k] = value }
      end
    end

    def kill_commands
      WINDOWS ? [1] : [:INT, :TERM]
    end

    def kill_command
      kill_commands[0]
    end

    def delete_pid!
      File.delete(pid_file) if File.exist?(pid_file)
    end
  end

end