commit 2f1c6b5bfb3ebbadde902db6e5d540530976de36
Author: Marc Dequènes (Duck) <Duck@DuckCorp.org>
Date:   Sun Jun 20 17:22:26 2010 +0200

    [evol] configuration defaults and schema checking (closes 21)

diff --git a/bin/postman b/bin/postman
index eed369a..0d90298 100755
--- a/bin/postman
+++ b/bin/postman
@@ -34,6 +34,16 @@ require 'cyborghood/command_runner'
 
 
 module CyborgHood
+  # example for specific validation rules of the bot-specific config file
+  #class Config
+  #  protected
+  #  class PostmanValidator < CyborgHoodValidator
+  #    def validate_hook_in(value, rule, path, msg_list)
+  #      msg_list << "Youhou !!!"
+  #    end
+  #  end
+  #end
+
   module PostmanHome
     include I18nTranslation
     bindtextdomain("cyborghood_postman", {:path => Config::L10N_DIR, :charset => "UTF-8"})
diff --git a/data/cyborghood/default_config/global.yaml b/data/cyborghood/default_config/global.yaml
new file mode 100755
index 0000000..f465656
--- /dev/null
+++ b/data/cyborghood/default_config/global.yaml
@@ -0,0 +1,11 @@
+---
+log:
+  console_level: normal
+debug:
+  flags: []
+contact: {}
+i18n: {}
+ldap:
+  host: localhost
+  port: 389
+  scope: sub
diff --git a/data/cyborghood/default_config/postman.yaml b/data/cyborghood/default_config/postman.yaml
new file mode 100755
index 0000000..bb7e83a
--- /dev/null
+++ b/data/cyborghood/default_config/postman.yaml
@@ -0,0 +1,9 @@
+---
+imap:
+  host: localhost
+  min_check_interval: 600
+mail:
+  smtp_server: localhost
+  smtp_port: 25
+  max_drift_time: 3600
+dns: {}
diff --git a/data/cyborghood/schema/global.yaml b/data/cyborghood/schema/global.yaml
new file mode 100644
index 0000000..a65d5b3
--- /dev/null
+++ b/data/cyborghood/schema/global.yaml
@@ -0,0 +1,37 @@
+---
+type: map
+mapping:
+  "log":
+    type: map
+    mapping:
+      "path": {type: str, required: yes, name: LogPath}
+      "console_level": {type: str, enum: [quiet, normal, verbose]}
+  "debug":
+    type: map
+    mapping:
+      "flags":
+        type: seq
+        sequence:
+          - {type: str, enum: [debug_nomaildeletion, debug_ignorereplay, debug_imapverbose]}
+  "contact":
+    type: map
+    mapping:
+      "name": {type: str}
+      "email": {type: str}
+      "url": {type: str}
+  "i18n":
+    type: map
+    mapping:
+      "restricted_language_set":
+        type: seq
+        sequence:
+          - type: str
+  # temporary location
+  "ldap":
+    type: map
+    required: true
+    name: LdapMapping
+    mapping:
+      =:
+        type: any
+
diff --git a/data/cyborghood/schema/postman.yaml b/data/cyborghood/schema/postman.yaml
new file mode 100755
index 0000000..430bcab
--- /dev/null
+++ b/data/cyborghood/schema/postman.yaml
@@ -0,0 +1,25 @@
+---
+type: map
+mapping:
+  "imap":
+    type: map
+    mapping:
+      "host": {type: str}
+      "ca_cert": {type: str}
+      "login": {type: str, required: yes}
+      "passwd": {type: str, required: yes}
+      "min_check_interval": {type: int}
+  "mail":
+    type: map
+    mapping:
+      "from_address": {type: str}
+      "smtp_server": {type: str}
+      "smtp_port": {type: int}
+      "organization": {type: str}
+      "key_id": {type: str, required: yes}
+      "key_passphrase": {type: str, required: yes}
+      "max_drift_time": {type: int}
+  "dns":
+    type: map
+    mapping:
+      "master_zone_pattern": {type: str, required: yes}
diff --git a/lib/cyborghood.rb b/lib/cyborghood.rb
index 1f0a108..55c49a2 100644
--- a/lib/cyborghood.rb
+++ b/lib/cyborghood.rb
@@ -18,6 +18,7 @@
 
 $KCODE = 'UTF8'
 require 'jcode'
+require 'pp'
 require 'cyborghood/base/config'
 require 'cyborghood/base/info'
 require 'cyborghood/base/lang_additions'
diff --git a/lib/cyborghood/base/config.rb b/lib/cyborghood/base/config.rb
index 6d174d8..460589e 100644
--- a/lib/cyborghood/base/config.rb
+++ b/lib/cyborghood/base/config.rb
@@ -17,8 +17,9 @@
 #++
 
 require 'singleton'
-require 'yaml'
 require 'cyborghood/base/config_setup'
+require 'yaml'
+require 'kwalify'
 
 module CyborgHood
   class Config
@@ -29,10 +30,18 @@ module CyborgHood
 
     def self.load(name = nil)
       # load all config parts
-      config = fetch_config
+      g_config_raw = load_config_raw()
+      g_def = parse('config', 'global', g_config_raw)
+      g_default_config = load_default_config()
+      config = merge_into_default_config(g_default_config, g_def)
+
       unless name.nil?
-        config_spc = fetch_config(name)
-        config = merge_configs(config, config_spc) if config_spc
+        config_spc_raw = load_config_raw(name)
+        spc_def = parse('config', name, config_spc_raw)
+        default_config_spc = load_default_config(name)
+        spc_config = merge_into_default_config(default_config_spc, spc_def)
+
+        config = merge_into_default_config(config, spc_config)
       end
 
       # create config object for easier access
@@ -47,34 +56,134 @@ module CyborgHood
       end
     end
 
-    private
+    protected
+
+    def self.merge_into_default_config(conf_default, conf)
+      new_conf = conf_default.dup
+      conf.each_pair do |k, v|
+        if conf_default.has_key?(k) and conf_default[k].is_a?(Hash) and v.is_a?(Hash)
+          new_conf[k] = merge_into_default_config(conf_default[k], v)
+        elsif conf_default.has_key?(k) and conf_default[k].is_a?(Hash) and v.nil?
+          new_conf[k] = conf_default[k]
+        else
+          new_conf[k] = v
+        end
+      end
+      return new_conf
+    end
 
-    def self.fetch_config(name = nil)
+    def self.load_config_raw(name = nil)
       begin
         if name.nil?
           filename = CONFFILE_GLOBAL
         else
-          filename = sprintf(CONFFILE_BOT, name)
+          filename = CONFFILE_BOT % name
         end
         filepath = File.join(File.expand_path(CFG_DIR), filename)
-        str = File.read(filepath)
-        YAML.load(str)
+        File.read(filepath)
       rescue
         logger.fatal "Problems fetching configuration file '" + filepath + "': " + $!
         exit 1
       end
     end
 
-    def self.merge_configs(conf1, conf2)
-      new_conf = conf1.dup
-      conf2.each_pair do |k, v|
-        if conf1.has_key?(k) and conf1[k].is_a?(Hash) and v.is_a?(Hash)
-          new_conf[k] = merge_configs(conf1[k], v)
+    def self.parse(def_name, type, def_data_raw, validate = true, extra_data = nil)
+      schema = load_schema(type)
+
+      if validate
+        validator_klass = case type
+        when 'global'
+          GlobalValidator
         else
-          new_conf[k] = v
+          klass_name = type.capitalize + "Validator"
+          if self.const_defined? klass_name
+             self.const_get klass_name
+          else
+            CyborgHoodValidator
+          end
         end
+        validator = validator_klass.new(schema)
+        validator.extra_data = extra_data
+      else
+        validator = nil
+      end
+
+      # validate config with schema
+      parser = Kwalify::Yaml::Parser.new(validator)
+      def_data = parser.parse(def_data_raw, :filename => def_name, :untabify => true)
+      raise_if_validation_errors("#{type.capitalize} '#{def_name}'", parser.errors)
+
+      def_data
+    rescue
+      logger.fatal "Problems parsing %s file for '%s': %s" % [def_name, type, $!]
+      exit 1
+    end
+
+    def self.load_default_config(type = "global")
+      filename = File.join(DATA_DIR, "default_config", type + ".yaml")
+      default_config_raw = File.read(filename)
+      default_config = parse(':defaults:', type, default_config_raw, false)
+      default_config
+    rescue
+      logger.fatal "Could not load default config for '%s': %s" % [type, $!]
+      exit 1
+    end
+
+    def self.load_schema(type = "global")
+      schema_file = File.join(DATA_DIR, "schema", type + ".yaml")
+      schema = YAML.load_file(schema_file)
+
+      # validate schema
+      metavalidator = Kwalify::MetaValidator.instance
+      errors = metavalidator.validate(schema)
+      raise_if_validation_errors("'#{type}' schema", errors)
+
+      schema
+    rescue
+      logger.fatal "Could not load schema for '%s': %s" % [type, $!]
+      exit 1
+    end
+
+    protected
+
+    class CyborgHoodValidator < Kwalify::Validator
+      attr_accessor :extra_data
+
+      ## hook method called by Validator#validate()
+      def validate_hook(value, rule, path, errors)
+        msg_list = []
+
+        validate_hook_in(value, rule, path, msg_list)
+
+        msg_list.each do |msg|
+          errors << Kwalify::ValidationError.new(msg, path)
+        end
+      end
+
+      def validate_hook_in(value, rule, path, msg_list)
+      end
+    end
+
+    class GlobalValidator < CyborgHoodValidator
+      def validate_hook_in(value, rule, path, msg_list)
+        case rule.name
+        when 'LogPath'
+          unless File.directory? value
+            msg_list << "Log path must be an existing directory"
+          end
+        end
+      end
+    end
+
+    def self.raise_if_validation_errors(name, errors)
+      if errors and not errors.empty?
+        err_msg = []
+        for e in errors
+          err_msg << "[#{e.path}] #{e.message}"
+        end
+        logger.fatal "%s is not valid:\n%s" % [name, err_msg.join("\n")]
+        exit 1
       end
-      return new_conf
     end
   end
 end
diff --git a/lib/cyborghood/base/language.rb b/lib/cyborghood/base/language.rb
index 54d420f..58a8fc6 100644
--- a/lib/cyborghood/base/language.rb
+++ b/lib/cyborghood/base/language.rb
@@ -33,7 +33,7 @@ module CyborgHood
       list = ['en'] + Dir.new(Config::L10N_DIR).select{|d| File.directory?(File.join(Config::L10N_DIR, d)) and d[0..0] != "." }
       # local admin can restrict available languages
       # (may be useful if l10n is partial due to third party plugins)
-      list = list & @config.i18n.restricted_language_set if @config.i18n and @config.i18n.restricted_language_set
+      list = list & @config.i18n.restricted_language_set if @config.i18n.restricted_language_set
       list = ['en'] if list.empty?
       list
     end
diff --git a/lib/cyborghood/base/logger.rb b/lib/cyborghood/base/logger.rb
index daffead..35b4949 100644
--- a/lib/cyborghood/base/logger.rb
+++ b/lib/cyborghood/base/logger.rb
@@ -27,9 +27,9 @@ module CyborgHood
 
     def output_level(level)
       l = case level
-      when :verbose
+      when 'verbose'
         Log4r::DEBUG
-      when :quiet
+      when 'quiet'
         Log4r::FATAL
       else # normal
         Log4r::WARN
diff --git a/lib/cyborghood/cyborg.rb b/lib/cyborghood/cyborg.rb
index 255978e..93feae8 100644
--- a/lib/cyborghood/cyborg.rb
+++ b/lib/cyborghood/cyborg.rb
@@ -60,7 +60,7 @@ module CyborgHood
       begin
         @cl = Jabber::Client.new(@jid)
         @cl.allow_tls = true
-        @cl.ssl_capath = @config.xmpp.ssl_capath unless @config.xmpp.ssl_capath == nil
+        @cl.ssl_capath = @config.xmpp.ssl_capath unless @config.xmpp.ssl_capath.nil?
         reconnect
       rescue Jabber::ServerError => e
         if e.error.type == :cancel and e.error.code == 409
diff --git a/lib/cyborghood/imap.rb b/lib/cyborghood/imap.rb
index e4aace7..85fa99a 100644
--- a/lib/cyborghood/imap.rb
+++ b/lib/cyborghood/imap.rb
@@ -68,7 +68,7 @@ module CyborgHood
       @available_mails = 0
       @available_mails_mutex = Mutex.new
 
-      if not (@config.debug.nil? or @config.debug.flags.nil?) and @config.debug.flags.include?(:debug_imapverbose)
+      if  @config.debug.flags.include?('debug_imapverbose')
         Net::IMAP.debug = true
       end
     end
@@ -91,7 +91,7 @@ module CyborgHood
       end
 
       def delete
-        if @config.debug.nil? or @config.debug.flags.nil? or not @config.debug.flags.include?(:debug_nomaildeletion)
+        if not @config.debug.flags.include?('debug_nomaildeletion')
           @imap.store(@message_id, "+FLAGS", [:Deleted])
         end
       end
@@ -105,7 +105,8 @@ module CyborgHood
     def connect
       return true if @imap
 
-      # using SSL because TLS does not work in the NET::IMAP library
+      # using SSL because TLS does not work in the NET::IMAP library in Ruby 1.8
+      # (but available in 1.9)
       ssl = (not @params.ca_cert.nil?)
       port = @params.port || (ssl ? 993 : 143)
       check_ca = ssl
@@ -185,7 +186,7 @@ module CyborgHood
         begin
           check_inbox(&message_handler)
           # don't loop forever
-          return if not (@config.debug.nil? or @config.debug.flags.nil?) and @config.debug.flags.include?(:debug_nomaildeletion)
+          return if @config.debug.flags.include?('debug_nomaildeletion')
         end while waiting_mails?
 
         if  self.capabilities.include?("IDLE")
diff --git a/lib/cyborghood/mail.rb b/lib/cyborghood/mail.rb
index d363635..995925a 100644
--- a/lib/cyborghood/mail.rb
+++ b/lib/cyborghood/mail.rb
@@ -53,8 +53,6 @@ module CyborgHood
   class Mail < Delegator
     include I18nTranslation
 
-    DEFAULT_MAX_DRIFT_TIME = 3600
-
     attr_accessor :user, :signature_timestamp
 
     def initialize(msg = nil)
@@ -82,7 +80,7 @@ module CyborgHood
 
     def process
       if is_marked?
-        if not (@config.debug.nil? or @config.debug.flags.nil?) and @config.debug.flags.include?(:debug_ignorereplay)
+        if @config.debug.flags.include?('debug_ignorereplay')
           logger.warn "Debug: ignoring replay"
         else
           return MailReport.new(:error => _("Replay detected."))
@@ -146,8 +144,8 @@ module CyborgHood
     def deliver
       check_headers
 
-      smtp_server = @config.mail.smtp_server || "localhost"
-      smtp_port = @config.mail.smtp_port || 25
+      smtp_server = @config.mail.smtp_server
+      smtp_port = @config.mail.smtp_port
       smtp_from = @mail.from_addrs.collect{|a| a.address}.join(", ")
       smtp_to = @mail.to_addrs.collect{|a| a.address}
       Net::SMTP.start(smtp_server, smtp_port) do |smtp|
@@ -194,7 +192,7 @@ module CyborgHood
 
       drift = Time.new.to_i - sig_check.timestamp.to_i
       logger.debug "Signature drift time: #{drift}"
-      unless drift.abs < (@config.mail.max_drift_time || DEFAULT_MAX_DRIFT_TIME)
+      unless drift.abs < @config.mail.max_drift_time
         if drift > 0
           return MailReport.new(:error => _("The signature was made too long ago (check your system clock). Rejected message to avoid replay attacks."), :user => user)
         else
