require 'erb' require 'json/pure' # pure ruby implementation is required for our sorted trick to work. $KCODE = 'UTF8' unless RUBY_VERSION > "1.9.0" 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 :node attr_reader :manager alias :global :manager def initialize(manager=nil, node=nil) # keep a global pointer around to the config manager. used a lot in the eval strings and templates # (which are evaluated in the context of Config::Object) @manager = manager # 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 # # 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 evaluate ya2yaml(:syck_compatible => true) end def dump_json evaluate generate_json(self) end def evaluate evaluate_everything late_evaluate_everything end ## ## FETCHING VALUES ## 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 key =~ /\./ # for keys with with '.' in them, we start from the root object (@node). keys = key.split('.') value = @node.get!(keys.first) if value.is_a? Config::Object value.get!(keys[1..-1].join('.')) else value end elsif self.has_key?(key) evaluate_key(key) else raise NoMethodError.new(key, "No method '#{key}' for #{self.class}") end 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. # def deep_merge!(object, prefer_self=false) object.each do |key,new_value| old_value = self.fetch key, nil # 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" # merge hashes if old_value.is_a?(Hash) || new_value.is_a?(Hash) value = Config::Object.new(@manager, @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 arrays elsif old_value.is_a?(Array) || new_value.is_a?(Array) value = [] old_value.is_a?(Array) ? value += old_value : value << old_value new_value.is_a?(Array) ? value += new_value : value << new_value value = value.compact.uniq # merge nil elsif new_value.nil? value = old_value elsif old_value.nil? value = new_value # 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 strings and numbers else if prefer_self value = old_value else value = new_value end end # save value self[key] = value end self end # # like a reverse deep merge # (self takes precedence) # def inherit_from!(object) self.deep_merge!(object, true) end ## ## MACROS ## these are methods used when eval'ing a value in the .json configuration ## # # the list of all the nodes # def nodes global.nodes end # # returns a list of nodes that match the same environment # def nodes_like_me nodes[:environment => @node.environment] end class FileMissing < Exception attr_accessor :path, :options def initialize(path, options={}) @path = path @options = options end def to_s @path end end # # inserts the contents of a file # def file(filename, options={}) if filename.is_a? Symbol filename = [filename, @node.name] end filepath = Path.find_file(filename) if filepath if filepath =~ /\.erb$/ ERB.new(File.read(filepath), nil, '%<>').result(binding) else File.read(filepath) end else raise FileMissing.new(Path.named_path(filename), options) "" end end # # like #file, but allow missing files # def try_file(filename) return file(filename) rescue FileMissing return nil end # # returns what the file path will be, once the file is rsynced to the server. # an internal list of discovered file paths is saved, in order to rsync these files when needed. # # notes: # # * argument 'path' is relative to Path.provider/files or Path.provider_base/files # * the path returned by this method is absolute # * the path stored for use later by rsync is relative to Path.provider # * if the path does not exist locally, but exists in provider_base, then the default file from # provider_base is copied locally. this is required for rsync to work correctly. # def file_path(path) if path.is_a? Symbol path = [path, @node.name] end actual_path = Path.find_file(path) if actual_path.nil? Util::log 2, :skipping, "file_path(\"#{path}\") because there is no such file." nil else if actual_path =~ /^#{Regexp.escape(Path.provider_base)}/ # if file is under Path.provider_base, we must copy the default file to # to Path.provider in order for rsync to be able to sync the file. local_provider_path = actual_path.sub(/^#{Regexp.escape(Path.provider_base)}/, Path.provider) FileUtils.mkdir_p File.dirname(local_provider_path) FileUtils.cp_r actual_path, local_provider_path Util.log :created, Path.relative_path(local_provider_path) actual_path = local_provider_path end if File.directory?(actual_path) && actual_path !~ /\/$/ actual_path += '/' # ensure directories end with /, important for building rsync command end relative_path = Path.relative_path(actual_path) @node.file_paths << relative_path @node.manager.provider.hiera_sync_destination + '/' + relative_path end end # # inserts a named secret, generating it if needed. # # manager.export_secrets should be called later to capture any newly generated secrets. # def secret(name, length=32) @manager.secrets.set(name, Util::Secret.generate(length)) end # # return a fingerprint for a x509 certificate # def fingerprint(filename) "SHA256: " + X509.fingerprint("SHA256", Path.named_path(filename)) end # # records the list of hosts that are encountered for this node # def hostnames(nodes) @referenced_nodes ||= ObjectList.new if nodes.is_a? Config::Object nodes = ObjectList.new nodes end nodes.each_node do |node| @referenced_nodes[node.name] = node end nodes.values.collect do |node| node.domain.name end end # # generates entries needed for updating /etc/hosts on a node, but only including the IPs of the # other nodes we have encountered. # def hosts_file return nil unless @referenced_nodes entries = [] @referenced_nodes.each_node do |node| entries << "#{node.ip_address} #{node.name} #{node.domain.internal} #{node.domain.full}" end entries.join("\n") end def known_hosts_file return nil unless @referenced_nodes entries = [] @referenced_nodes.each_node do |node| hostnames = [node.name, node.domain.internal, node.domain.full, node.ip_address].join(',') pub_key = Util::read_file([:node_ssh_pub_key,node.name]) if pub_key entries << [hostnames, pub_key].join(' ') end end entries.join("\n") end protected # # walks the object tree, eval'ing all the attributes that are dynamic ruby (e.g. value starts with '= ') # def evaluate_everything keys.each do |key| obj = evaluate_key(key) if obj.is_a? Config::Object obj.evaluate_everything end end end # # some keys need to be evaluated 'late', after all the other keys have been evaluated. # def late_evaluate_everything if @late_eval_list @late_eval_list.each do |key, value| self[key] = evaluate_now(key, value) end end values.each do |obj| if obj.is_a? Config::Object obj.late_evaluate_everything end end end private # # fetches the value for the key, evaluating the value as ruby if it begins with '=' # def evaluate_key(key) value = fetch(key, nil) if !value.is_a?(String) value else if value =~ /^=> (.*)$/ value = evaluate_later(key, $1) elsif value =~ /^= (.*)$/ value = evaluate_now(key, $1) end self[key] = value value end end def evaluate_later(key, value) @late_eval_list ||= [] @late_eval_list << [key, value] '' end def evaluate_now(key, value) result = nil if LeapCli.log_level >= 2 result = @node.instance_eval(value) else begin result = @node.instance_eval(value) rescue SystemStackError => exc Util::log 0, :error, "while evaluating node '#{@node.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', @node.name) else Util::log :error, "while evaluating node '#{@node.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 end rescue SyntaxError, StandardError => exc Util::bail! do Util::log :error, "while evaluating node '#{@node.name}'" Util::log "offending key: #{key}", :indent => 1 Util::log "offending string: #{value}", :indent => 1 Util::log "error message: #{exc.inspect}", :indent => 1 end end end if result == "REQUIRED" Util::log 0, :warning, "required key \"#{key}\" is not set in node \"#{node.name}\"." end return result end # # Output json from ruby objects in such a manner that all the hashes and arrays are output in alphanumeric sorted order. # This is required so that our generated configs don't throw puppet or git for a tizzy fit. # # Beware: some hacky stuff ahead. # # This relies on the pure ruby implementation of JSON.generate (i.e. require 'json/pure') # see https://github.com/flori/json/blob/master/lib/json/pure/generator.rb # # The Oj way that we are not using: Oj.dump(obj, :mode => :compat, :indent => 2) # def generate_json(obj) # modify hash and array Hash.class_eval do alias_method :each_without_sort, :each def each(&block) keys.sort {|a,b| a.to_s <=> b.to_s }.each do |key| yield key, self[key] end end end Array.class_eval do alias_method :each_without_sort, :each def each(&block) sort {|a,b| a.to_s <=> b.to_s }.each_without_sort &block end end # generate json return_value = JSON.pretty_generate(obj) # restore hash and array Hash.class_eval {alias_method :each, :each_without_sort} Array.class_eval {alias_method :each, :each_without_sort} return return_value 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 end # class end # module end # module