about summary refs log tree commit homepage
path: root/lib/yahns/config.rb
diff options
context:
space:
mode:
Diffstat (limited to 'lib/yahns/config.rb')
-rw-r--r--lib/yahns/config.rb341
1 files changed, 341 insertions, 0 deletions
diff --git a/lib/yahns/config.rb b/lib/yahns/config.rb
new file mode 100644
index 0000000..1d4d110
--- /dev/null
+++ b/lib/yahns/config.rb
@@ -0,0 +1,341 @@
+# -*- encoding: binary -*-
+# Copyright (C) 2013, Eric Wong <normalperson@yhbt.net> and all contributors
+# License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
+#
+# Implements a DSL for configuring a yahns server.
+# See http://yahns.yhbt.net/examples/yahns_multi.conf.rb for a full
+# example configuration file.
+class Yahns::Config # :nodoc:
+  APP_CLASS = {} # public, see yahns/rack for usage example
+  CfgBlock = Struct.new(:type, :ctx) # :nodoc:
+  attr_reader :config_file, :config_listeners, :set
+  attr_reader :qeggs, :app_ctx
+
+  def initialize(config_file = nil)
+    @config_file = config_file
+    @block = nil
+    config_reload!
+  end
+
+  def _check_in_block(ctx, var)
+    if ctx == nil
+      return var if @block == nil
+      msg = "#{var} must be called outside of #{@block.type}"
+    else
+      return var if @block && ctx == @block.type
+      msg = @block ? "may not be used inside a #{@block.type} block" :
+                     "must be used with a #{ctx} block"
+    end
+    raise ArgumentError, msg
+  end
+
+  def config_reload! #:nodoc:
+    # app_instance:app_ctx is a 1:N relationship
+    @config_listeners = {} # name/address -> options
+    @app_ctx = []
+    @set = Hash.new(:unset)
+    @qeggs = {}
+    @app_instances = {}
+
+    # set defaults:
+    client_expire_threshold(0.5) # default is half of the open file limit
+
+    instance_eval(File.read(@config_file), @config_file) if @config_file
+
+    # working_directory binds immediately (easier error checking that way),
+    # now ensure any paths we changed are correctly set.
+    [ :pid, :stderr_path, :stdout_path ].each do |var|
+      String === (path = @set[var]) or next
+      path = File.expand_path(path)
+      File.writable?(path) || File.writable?(File.dirname(path)) or \
+            raise ArgumentError, "directory for #{var}=#{path} not writable"
+    end
+  end
+
+  def logger(obj)
+    var = :logger
+    %w(debug info warn error fatal).each do |m|
+      obj.respond_to?(m) and next
+      raise ArgumentError, "#{var}=#{obj} does not respond to method=#{m}"
+    end
+    if @block
+      if @block.ctx.respond_to?(:logger=)
+        @block.ctx.logger = obj
+      else
+        raise ArgumentError, "#{var} not valid inside #{@block.type}"
+      end
+    else
+      @set[var] = obj
+    end
+  end
+
+  def worker_processes(nr)
+    # TODO: allow zero
+    var = _check_in_block(nil, :worker_processes)
+    @set[var] = _check_int(var, nr, 1)
+  end
+
+  # sets the +path+ for the PID file of the yahns master process
+  def pid(path)
+    _set_path(:pid, path)
+  end
+
+  def stderr_path(path)
+    _set_path(:stderr_path, path)
+  end
+
+  def stdout_path(path)
+    _set_path(:stdout_path, path)
+  end
+
+  def value(var)
+    val = @set[var]
+    val == :unset ? nil : val
+  end
+
+  # sets the working directory for yahns.  This ensures SIGUSR2 will
+  # start a new instance of yahns in this directory.  This may be
+  # a symlink, a common scenario for Capistrano users.  Unlike
+  # all other yahns configuration directives, this binds immediately
+  # for error checking and cannot be undone by unsetting it in the
+  # configuration file and reloading.
+  def working_directory(path)
+    var = :working_directory
+    @app_ctx.empty? or
+      raise ArgumentError, "#{var} must be declared before any apps"
+
+    # just let chdir raise errors
+    path = File.expand_path(path)
+    if @config_file &&
+       @config_file[0] != ?/ &&
+       ! File.readable?("#{path}/#@config_file")
+      raise ArgumentError,
+            "config_file=#@config_file would not be accessible in" \
+            " #{var}=#{path}"
+    end
+    Dir.chdir(path)
+    @set[var] = ENV["PWD"] = path
+  end
+
+  # Runs worker processes as the specified +user+ and +group+.
+  # The master process always stays running as the user who started it.
+  # This switch will occur after calling the after_fork hooks, and only
+  # if the Worker#user method is not called in the after_fork hooks
+  # +group+ is optional and will not change if unspecified.
+  def user(user, group = nil)
+    var = :user
+    @block and raise "#{var} is not valid inside #{@block.type}"
+    # raises ArgumentError on invalid user/group
+    Etc.getpwnam(user)
+    Etc.getgrnam(group) if group
+    @set[var] = [ user, group ]
+  end
+
+  def _set_path(var, path) #:nodoc:
+    _check_in_block(nil, var)
+    case path
+    when NilClass, String
+      @set[var] = path
+    else
+      raise ArgumentError
+    end
+  end
+
+  def listen(address, options = {})
+    options = options.dup
+    var = _check_in_block(:app, :listen)
+    address = expand_addr(address)
+    String === address or
+      raise ArgumentError, "address=#{address.inspect} must be a string"
+    [ :umask, :backlog, :sndbuf, :rcvbuf ].each do |key|
+      value = options[key] or next
+      Integer === value or
+        raise ArgumentError, "#{var}: not an integer: #{key}=#{value.inspect}"
+    end
+    [ :ipv6only ].each do |key|
+      (value = options[key]).nil? and next
+      [ true, false ].include?(value) or
+        raise ArgumentError, "#{var}: not boolean: #{key}=#{value.inspect}"
+    end
+
+    options[:yahns_app_ctx] = @block.ctx
+    @config_listeners.include?(address) and
+      raise ArgumentError, "listen #{address} already in use"
+    @config_listeners[address] = options
+  end
+
+  # expands "unix:path/to/foo" to a socket relative to the current path
+  # expands pathnames of sockets if relative to "~" or "~username"
+  # expands "*:port and ":port" to "0.0.0.0:port"
+  def expand_addr(address) #:nodoc:
+    return "0.0.0.0:#{address}" if Integer === address
+    return address unless String === address
+
+    case address
+    when %r{\Aunix:(.*)\z}
+      File.expand_path($1)
+    when %r{\A~}
+      File.expand_path(address)
+    when %r{\A(?:\*:)?(\d+)\z}
+      "0.0.0.0:#$1"
+    when %r{\A\[([a-fA-F0-9:]+)\]\z}, %r/\A((?:\d+\.){3}\d+)\z/
+      canonicalize_tcp($1, 80)
+    when %r{\A\[([a-fA-F0-9:]+)\]:(\d+)\z}, %r{\A(.*):(\d+)\z}
+      canonicalize_tcp($1, $2.to_i)
+    else
+      address
+    end
+  end
+
+  def canonicalize_tcp(addr, port)
+    packed = Socket.pack_sockaddr_in(port, addr)
+    port, addr = Socket.unpack_sockaddr_in(packed)
+    /:/ =~ addr ? "[#{addr}]:#{port}" : "#{addr}:#{port}"
+  end
+
+  def queue(name = :default, &block)
+    var = :queue
+    qegg = @qeggs[name] ||= Yahns::QueueEgg.new
+    prev_block = @block
+    _check_in_block(:app, var) if prev_block
+    if block_given?
+      @block = CfgBlock.new(:queue, qegg)
+      instance_eval(&block)
+      @block = prev_block
+    end
+    prev_block.ctx.qegg = qegg if prev_block
+  end
+
+  # queue parameters (Yahns::QueueEgg)
+  %w(max_events worker_threads).each do |_v|
+    eval(
+    %Q(def #{_v}(val);) <<
+    %Q(  _check_in_block(:queue, :#{_v});) <<
+    %Q(  @block.ctx.__send__("#{_v}=", _check_int(:#{_v}, val, 1));) <<
+    %Q(end)
+    )
+  end
+
+  def _check_int(var, n, min)
+    Integer === n or raise ArgumentError, "not an integer: #{var}=#{n.inspect}"
+    n >= min or raise ArgumentError, "too low (< #{min}): #{var}=#{n.inspect}"
+    n
+  end
+
+  # global
+  def client_expire_threshold(val)
+    var = _check_in_block(nil, :client_expire_threshold)
+    val > 0 or raise ArgumentError, "#{var} must be > 0"
+    case val
+    when Float
+      val <= 1.0 or raise ArgumentError, "#{var} must be <= 1.0 if a ratio"
+    when Integer
+    else
+      raise ArgumentError, "#{var} must be a float or integer"
+    end
+    @set[var] = val
+  end
+
+  # type = :rack
+  def app(type, *args, &block)
+    var = _check_in_block(nil, :app)
+    file = "yahns/#{type.to_s}"
+    begin
+      require file
+    rescue LoadError => e
+      raise ArgumentError, "#{type.inspect} is not a supported app type",
+            e.backtrace
+    end
+    klass = APP_CLASS[type] or
+      raise TypeError,
+        "#{var}: #{file} did not register #{type} in #{self.class}::APP_CLASS"
+
+    # apps may have multiple configurator contexts
+    app = @app_instances[klass.instance_key(*args)] = klass.new(*args)
+    ctx = app.config_context
+    if block_given?
+      @block = CfgBlock.new(:app, ctx)
+      instance_eval(&block)
+      @block = nil
+    end
+    @app_ctx << ctx
+  end
+
+  def _check_bool(var, val)
+    return val if [ true, false ].include?(val)
+    raise ArgumentError, "#{var} must be boolean"
+  end
+
+  # boolean config directives for app
+  %w(check_client_connection
+     output_buffering
+     persistent_connections).each do |_v|
+    eval(
+    %Q(def #{_v}(bool);) <<
+    %Q(  _check_in_block(:app, :#{_v});) <<
+    %Q(  @block.ctx.__send__("#{_v}=", _check_bool(:#{_v}, bool));) <<
+    %Q(end)
+    )
+  end
+
+  # integer config directives for app
+  {
+    # config name, minimum value
+    client_body_buffer_size: 1,
+    client_max_body_size: 0,
+    client_header_buffer_size: 1,
+    client_max_header_size: 1,
+    client_timeout: 0,
+  }.each do |_v,minval|
+    eval(
+    %Q(def #{_v}(val);) <<
+    %Q(  _check_in_block(:app, :#{_v});) <<
+    %Q(  @block.ctx.__send__("#{_v}=", _check_int(:#{_v}, val, #{minval}));) <<
+    %Q(end)
+    )
+  end
+
+  def input_buffering(val)
+    var = _check_in_block(:app, :input_buffering)
+    ok = [ :lazy, true, false ]
+    ok.include?(val) or
+      raise ArgumentError, "`#{var}' must be one of: #{ok.inspect}"
+    @block.ctx.__send__("#{var}=", val)
+  end
+
+  # used to configure rack.errors destination
+  def errors(val)
+    var = _check_in_block(:app, :errors)
+    if String === val
+      # we've already bound working_directory by the time we get here
+      val = File.open(File.expand_path(val), "a")
+      val.binmode
+      val.sync = true
+    else
+      rt = %w(puts write flush).map(&:to_sym) # match Rack::Lint
+      rt.all? { |m| val.respond_to?(m) } or raise ArgumentError,
+                   "`#{var}' destination must respond to all of: #{rt.inspect}"
+    end
+    @block.ctx.__send__("#{var}=", val)
+  end
+
+  def commit!(server)
+    # redirect IOs
+    { stdout_path: $stdout, stderr_path: $stderr }.each do |key, io|
+      path = @set[key]
+      if path == :unset && server.daemon_pipe
+        @set[key] = path = "/dev/null"
+      end
+      File.open(path, 'a') { |fp| io.reopen(fp) } if String === path
+      io.sync = true
+    end
+
+    [ :logger, :pid, :worker_processes ].each do |var|
+      val = @set[var]
+      server.__send__("#{var}=", val) if val != :unset
+    end
+    queue(:default) if @qeggs.empty?
+    @qeggs.each_value { |qegg| qegg.logger ||= server.logger }
+    @app_ctx.each { |app| app.logger ||= server.logger }
+  end
+end