diff options
Diffstat (limited to 'lib/leap_cli/config/object.rb')
-rw-r--r-- | lib/leap_cli/config/object.rb | 454 |
1 files changed, 454 insertions, 0 deletions
diff --git a/lib/leap_cli/config/object.rb b/lib/leap_cli/config/object.rb new file mode 100644 index 00000000..16c41999 --- /dev/null +++ b/lib/leap_cli/config/object.rb @@ -0,0 +1,454 @@ +# encoding: utf-8 + +require 'erb' +require 'json/pure' # pure ruby implementation is required for our sorted trick to work. + +if $ruby_version < [1,9] + $KCODE = 'UTF8' +end +require 'ya2yaml' # pure ruby yaml + +module LeapCli + module Config + + # + # This class represents the configuration for a single node, service, or tag. + # Also, all the nested hashes are also of this type. + # + # It is called 'object' because it corresponds to an Object in JSON. + # + class Object < Hash + + attr_reader :env + attr_reader :node + + def initialize(environment=nil, node=nil) + raise ArgumentError unless environment.nil? || environment.is_a?(Config::Environment) + @env = environment + # an object that is a node as @node equal to self, otherwise all the + # child objects point back to the top level node. + @node = node || self + end + + def manager + @env.manager + end + + # + # TODO: deprecate node.global() + # + def global + @env + end + + def environment=(e) + self.store('environment', e) + end + + def environment + self['environment'] + end + + def duplicate(env) + new_object = self.deep_dup + new_object.set_environment(env, new_object) + end + + # + # export YAML + # + # We use pure ruby yaml exporter ya2yaml instead of SYCK or PSYCH because it + # allows us greater compatibility regardless of installed ruby version and + # greater control over how the yaml is exported (sorted keys, in particular). + # + def dump_yaml + evaluate(@node) + sorted_ya2yaml(:syck_compatible => true) + end + + # + # export JSON + # + def dump_json(options={}) + evaluate(@node) + if options[:format] == :compact + return self.to_json + else + excluded = {} + if options[:exclude] + options[:exclude].each do |key| + excluded[key] = self[key] + self.delete(key) + end + end + json_str = JSON.sorted_generate(self) + if excluded.any? + self.merge!(excluded) + end + return json_str + end + end + + def evaluate(context=@node) + evaluate_everything(context) + late_evaluate_everything(context) + end + + ## + ## FETCHING VALUES + ## + + def [](key) + get(key) + end + + # Overrride some default methods in Hash that are likely to + # be used as attributes. + alias_method :hkey, :key + def key; get('key'); end + + # + # make hash addressable like an object (e.g. obj['name'] available as obj.name) + # + def method_missing(method, *args, &block) + get!(method) + end + + def get(key) + begin + get!(key) + rescue NoMethodError + nil + end + end + + # override behavior of #default() from Hash + def default + get!('default') + end + + # + # Like a normal Hash#[], except: + # + # (1) lazily eval dynamic values when we encounter them. (i.e. strings that start with "= ") + # + # (2) support for nested references in a single string (e.g. ['a.b'] is the same as ['a']['b']) + # the dot path is always absolute, starting at the top-most object. + # + def get!(key) + key = key.to_s + if self.has_key?(key) + fetch_value(key) + elsif key =~ /\./ + # for keys with with '.' in them, we start from the root object (@node). + keys = key.split('.') + value = self.get!(keys.first) + if value.is_a? Config::Object + value.get!(keys[1..-1].join('.')) + else + value + end + else + raise NoMethodError.new(key, "No method '#{key}' for #{self.class}") + end + end + + # + # works like Hash#store(key, value), but supports our nested dot notation, + # just like get() does. + # + def set(key, value) + key = key.to_s + # for keys with with '.' in them, we pop off the first part + # and recursively call ourselves. + if key =~ /\./ + keys = key.split('.') + parent_value = self.get!(keys.first) + if parent_value.is_a?(Config::Object) + parent_value.set(keys[1..-1].join('.'), value) + else + parent_value.store(keys[1..-1].join('.'), value) + end + else + self.store(key, value) + end + return nil + end + + ## + ## COPYING + ## + + # + # A deep (recursive) merge with another Config::Object. + # + # If prefer_self is set to true, the value from self will be picked when there is a conflict + # that cannot be merged. + # + # Merging rules: + # + # - If a value is a hash, we recursively merge it. + # - If the value is simple, like a string, the new one overwrites the value. + # - If the value is an array: + # - If both old and new values are arrays, the new one replaces the old. + # - If one of the values is simple but the other is an array, the simple is added to the array. + # + def deep_merge!(object, prefer_self=false) + object.each do |key,new_value| + if self.has_key?('+'+key) + mode = :add + old_value = self.fetch '+'+key, nil + self.delete('+'+key) + elsif self.has_key?('-'+key) + mode = :subtract + old_value = self.fetch '-'+key, nil + self.delete('-'+key) + elsif self.has_key?('!'+key) + mode = :replace + old_value = self.fetch '!'+key, nil + self.delete('!'+key) + else + mode = :normal + old_value = self.fetch key, nil + end + + # clean up boolean + new_value = true if new_value == "true" + new_value = false if new_value == "false" + old_value = true if old_value == "true" + old_value = false if old_value == "false" + + # force replace? + if mode == :replace && prefer_self + value = old_value + + # merge hashes + elsif old_value.is_a?(Hash) || new_value.is_a?(Hash) + value = Config::Object.new(@env, @node) + old_value.is_a?(Hash) ? value.deep_merge!(old_value) : (value[key] = old_value if !old_value.nil?) + new_value.is_a?(Hash) ? value.deep_merge!(new_value, prefer_self) : (value[key] = new_value if !new_value.nil?) + + # merge nil + elsif new_value.nil? + value = old_value + elsif old_value.nil? + value = new_value + + # merge arrays when one value is not an array + elsif old_value.is_a?(Array) && !new_value.is_a?(Array) + (value = (old_value.dup << new_value).compact.uniq).delete('REQUIRED') + elsif new_value.is_a?(Array) && !old_value.is_a?(Array) + (value = (new_value.dup << old_value).compact.uniq).delete('REQUIRED') + + # merge two arrays + elsif old_value.is_a?(Array) && new_value.is_a?(Array) + if mode == :add + value = (old_value + new_value).sort.uniq + elsif mode == :subtract + value = new_value - old_value + elsif prefer_self + value = old_value + else + value = new_value + end + + # catch errors + elsif type_mismatch?(old_value, new_value) + raise 'Type mismatch. Cannot merge %s (%s) with %s (%s). Key is "%s", name is "%s".' % [ + old_value.inspect, old_value.class, + new_value.inspect, new_value.class, + key, self.class + ] + + # merge simple strings & numbers + else + if prefer_self + value = old_value + else + value = new_value + end + end + + # save value + self[key] = value + end + self + end + + def set_environment(env, node) + @env = env + @node = node + self.each do |key, value| + if value.is_a?(Config::Object) + value.set_environment(env, node) + end + end + end + + # + # like a reverse deep merge + # (self takes precedence) + # + def inherit_from!(object) + self.deep_merge!(object, true) + end + + # + # Make a copy of ourselves, except only including the specified keys. + # + # Also, the result is flattened to a single hash, so a key of 'a.b' becomes 'a_b' + # + def pick(*keys) + keys.map(&:to_s).inject(self.class.new(@manager)) do |hsh, key| + value = self.get(key) + if !value.nil? + hsh[key.gsub('.','_')] = value + end + hsh + end + end + + def eval_file(filename) + evaluate_ruby(filename, File.read(filename)) + end + + protected + + # + # walks the object tree, eval'ing all the attributes that are dynamic ruby (e.g. value starts with '= ') + # + def evaluate_everything(context) + keys.each do |key| + obj = fetch_value(key, context) + if is_required_value_not_set?(obj) + Util::log 0, :warning, "required property \"#{key}\" is not set in node \"#{node.name}\"." + elsif obj.is_a? Config::Object + obj.evaluate_everything(context) + end + end + end + + # + # some keys need to be evaluated 'late', after all the other keys have been evaluated. + # + def late_evaluate_everything(context) + if @late_eval_list + @late_eval_list.each do |key, value| + self[key] = context.evaluate_ruby(key, value) + if is_required_value_not_set?(self[key]) + Util::log 0, :warning, "required property \"#{key}\" is not set in node \"#{node.name}\"." + end + end + end + values.each do |obj| + if obj.is_a? Config::Object + obj.late_evaluate_everything(context) + end + end + end + + # + # evaluates the string `value` as ruby in the context of self. + # (`key` is just passed for debugging purposes) + # + def evaluate_ruby(key, value) + self.instance_eval(value, key, 1) + rescue ConfigError => exc + raise exc # pass through + rescue SystemStackError => exc + Util::log 0, :error, "while evaluating node '#{self.name}'" + Util::log 0, "offending key: #{key}", :indent => 1 + Util::log 0, "offending string: #{value}", :indent => 1 + Util::log 0, "STACK OVERFLOW, BAILING OUT. There must be an eval loop of death (variables with circular dependencies).", :indent => 1 + raise SystemExit.new(1) + rescue FileMissing => exc + Util::bail! do + if exc.options[:missing] + Util::log :missing, exc.options[:missing].gsub('$node', self.name).gsub('$file', exc.path) + else + Util::log :error, "while evaluating node '#{self.name}'" + Util::log "offending key: #{key}", :indent => 1 + Util::log "offending string: #{value}", :indent => 1 + Util::log "error message: no file '#{exc}'", :indent => 1 + end + raise exc if DEBUG + end + rescue AssertionFailed => exc + Util.bail! do + Util::log :failed, "assertion while evaluating node '#{self.name}'" + Util::log 'assertion: %s' % exc.assertion, :indent => 1 + Util::log "offending key: #{key}", :indent => 1 + raise exc if DEBUG + end + rescue SyntaxError, StandardError => exc + Util::bail! do + Util::log :error, "while evaluating node '#{self.name}'" + Util::log "offending key: #{key}", :indent => 1 + Util::log "offending string: #{value}", :indent => 1 + Util::log "error message: #{exc.inspect}", :indent => 1 + raise exc if DEBUG + end + end + + private + + # + # fetches the value for the key, evaluating the value as ruby if it begins with '=' + # + def fetch_value(key, context=@node) + value = fetch(key, nil) + if value.is_a?(String) && value =~ /^=/ + # strings prefix with '=' are evaluated as ruby code. + if value =~ /^=> (.*)$/ + value = evaluate_later(key, $1) + elsif value =~ /^= (.*)$/ + value = context.evaluate_ruby(key, $1) + end + self[key] = value + elsif value.is_a?(Proc) + # the value might be a proc, set by a 'control' file + self[key] = value.call + end + return value + end + + def evaluate_later(key, value) + @late_eval_list ||= [] + @late_eval_list << [key, value] + '<evaluate later>' + end + + # + # when merging, we raise an error if this method returns true for the two values. + # + def type_mismatch?(old_value, new_value) + if old_value.is_a?(Boolean) && new_value.is_a?(Boolean) + # note: FalseClass and TrueClass are different classes + # so we can't do old_value.class == new_value.class + return false + elsif old_value.is_a?(String) && old_value =~ /^=/ + # pass through macros, since we don't know what the type will eventually be. + return false + elsif new_value.is_a?(String) && new_value =~ /^=/ + return false + elsif old_value.class == new_value.class + return false + else + return true + end + end + + # + # returns true if the value has not been changed and the default is "REQUIRED" + # + def is_required_value_not_set?(value) + if value.is_a? Array + value == ["REQUIRED"] + else + value == "REQUIRED" + end + end + + end # class + end # module +end # module
\ No newline at end of file |