config-files-api/config_files_api

View on GitHub
lib/cfa/augeas_parser.rb

Summary

Maintainability
A
3 hrs
Test Coverage
# frozen_string_literal: true

require "set"
require "augeas"
require "forwardable"
require "cfa/placer"

# CFA: Configuration Files API
module CFA
  # A building block for {AugeasTree}.
  #
  # Intuitively the tree is made of hashes where keys may be duplicated,
  # so it is implemented as a sequence of hashes with two keys, :key and :value.
  #
  # A `:key` is a String.
  # The key may have a collection suffix "[]". Note that in contrast
  # with the underlying {::Augeas} library, an integer index is not present
  # (which should make it easier to modify collections of elements).
  #
  # A `:value` is either a String, or an {AugeasTree},
  # or an {AugeasTreeValue} (which combines both).
  #
  # An `:operation` is an internal variable holding modification of Augeas
  # structure. It is used for minimizing modifications of source files. Its
  # possible values are
  # - `:keep` when the value is untouched
  # - `:modify` when the `:value` changed but the `:key` is the same
  # - `:remove` when it is going to be removed, and
  # - `:add` when a new element is added.
  #
  # An `:orig_key` is an internal variable used to hold the original key
  # including its index.
  #
  # @return [Hash{Symbol => String, AugeasTree}]
  #
  # @todo Unify naming: entry, element
  class AugeasElement < Hash
  end

  # Error that is raised when augeas fail for some reason.
  # Base class for more specialized exceptions.
  class AugeasError < RuntimeError
  end

  # Error in augeas itself like broken lens
  class AugeasInternalError < AugeasError
    attr_reader :details
    attr_reader :aug_message

    def initialize(message, details)
      @aug_message = message
      @details = details

      super("Augeas error: #{message}. Details: #{details}.")
    end
  end

  # Parsing error
  class AugeasParsingError < AugeasError
    attr_reader :aug_message
    attr_reader :file
    attr_reader :line
    attr_reader :character
    attr_reader :lens
    attr_reader :file_content

    def initialize(params)
      @aug_message = params[:message]
      @file = params[:file]
      @line = params[:line]
      @char = params[:char]
      @lens = params[:lens]
      @file_content = params[:file_content]

      super("Augeas parsing error: #{@aug_message}" \
        " at #{@file}:#{@line}:#{@char}, lens #{@lens}"
      )
    end
  end

  # Serializing error
  class AugeasSerializingError < AugeasError
    attr_reader :aug_message
    attr_reader :file
    attr_reader :lens
    attr_reader :aug_tree

    def initialize(params)
      @aug_message = params[:message]
      @file = params[:file]
      @lens = params[:lens]
      @aug_tree = params[:aug_tree]

      super("Augeas serializing error: #{@aug_message}" \
        " for #{@file} with lens #{@lens}"
      )
    end
  end

  # Represents list of same config options in augeas.
  # For example comments are often stored in collections.
  class AugeasCollection
    extend Forwardable
    def initialize(tree, name)
      @tree = tree
      @name = name
      load_collection
    end

    def_delegators :@collection, :[], :empty?, :each, :map, :any?, :all?, :none?

    def add(value, placer = AppendPlacer.new)
      element = placer.new_element(@tree)
      element[:key] = augeas_name
      element[:value] = value
      element[:operation] = :add
      # FIXME: load_collection missing here
    end

    def delete(value)
      to_delete, to_mark = to_remove(value)
                           .partition { |e| e[:operation] == :add }
      @tree.all_data.delete_if { |e| to_delete.include?(e) }

      to_mark.each { |e| e[:operation] = :remove }

      load_collection
    end

  private

    def load_collection
      entries = @tree.data.select do |entry|
        entry[:key] == augeas_name && entry[:operation] != :remove
      end
      @collection = entries.map { |e| e[:value] }.freeze
    end

    def augeas_name
      @name + "[]"
    end

    def to_remove(value)
      key = augeas_name

      @tree.data.select do |entry|
        entry[:key] == key && value_match?(entry[:value], value)
      end
    end

    def value_match?(value, match)
      if match.is_a?(Regexp)
        value =~ match
      else
        value == match
      end
    end
  end

  # Represents a node that contains both a value and a subtree below it.
  # For easier traversal it forwards `#[]` to the subtree.
  class AugeasTreeValue
    # @return [String] the value in the node
    attr_reader :value
    # @return [AugeasTree] the subtree below the node
    attr_accessor :tree

    def initialize(tree, value)
      @tree = tree
      @value = value
      @modified = false
    end

    # (see AugeasTree#[])
    def [](key)
      tree[key]
    end

    def value=(value)
      @value = value
      @modified = true
    end

    def ==(other)
      [:class, :value, :tree].all? do |a|
        public_send(a) == other.public_send(a)
      end
    end

    # @return true if the value has been modified
    def modified?
      @modified
    end

    # For objects of class Object, eql? is synonymous with ==:
    # http://ruby-doc.org/core-2.3.3/Object.html#method-i-eql-3F
    alias_method :eql?, :==
  end

  # Represents a parsed Augeas config tree with user friendly methods
  class AugeasTree
    # Low level access to Augeas structure
    #
    # An ordered mapping, represented by an Array of AugeasElement, but without
    # any removed elements.
    #
    # @see AugeasElement
    #
    # @return [Array<Hash{Symbol => Object}>] a frozen array as it is
    #    just a copy of the real data
    def data
      @data.reject { |e| e[:operation] == :remove }.freeze
    end

    # low level access to all AugeasElement including ones marked for removal
    def all_data
      @data
    end

    def initialize
      @data = []
    end

    # Gets new unique id in numberic sequence. Useful for augeas models that
    # using sequences like /etc/hosts . It have keys like "1", "2" and when
    # adding new one it need to find new key.
    def unique_id
      # check all_data instead of data, as we have to not reuse deleted key
      ids = Set.new(all_data.map { |e| e[:key] })
      id = 1
      loop do
        return id.to_s unless ids.include?(id.to_s)

        id += 1
      end
    end

    # @return [AugeasCollection] collection for *key*
    def collection(key)
      AugeasCollection.new(self, key)
    end

    # @param [String, Matcher] matcher
    def delete(matcher)
      return if matcher.nil?

      unless matcher.is_a?(CFA::Matcher)
        matcher = CFA::Matcher.new(key: matcher)
      end
      to_remove = @data.select(&matcher)

      to_delete, to_mark = to_remove.partition { |e| e[:operation] == :add }
      @data -= to_delete
      to_mark.each { |e| e[:operation] = :remove }
    end

    # Adds the given *value* for *key* in the tree.
    #
    # By default an AppendPlacer is used which produces duplicate keys
    # but ReplacePlacer can be used to replace the *first* duplicate.
    # @param key [String]
    # @param value [String,AugeasTree,AugeasTreeValue]
    # @param placer [Placer] determines where to insert value in tree.
    #   Useful e.g. to specify order of keys or placing comment above of given
    #   key.
    def add(key, value, placer = AppendPlacer.new)
      element = placer.new_element(self)
      element[:key] = key
      element[:value] = value
      element[:operation] = :add
    end

    # Finds given *key* in tree.
    # @param key [String]
    # @return [String,AugeasTree,AugeasTreeValue,nil] the first value for *key*,
    #   or `nil` if not found
    def [](key)
      entry = @data.find { |d| d[:key] == key && d[:operation] != :remove }
      return entry[:value] if entry

      nil
    end

    # Replace the first value for *key* with *value*.
    # Append a new element if *key* did not exist.
    # If *key* was previously removed, then put it back to its old position.
    # @param key [String]
    # @param value [String, AugeasTree, AugeasTreeValue]
    def []=(key, value)
      new_entry = entry_to_modify(key, value)
      new_entry[:key] = key
      new_entry[:value] = value
    end

    # @param matcher [Matcher]
    # @return [Array<AugeasElement>] matching elements
    def select(matcher)
      data.select(&matcher)
    end

    def ==(other)
      return false if self.class != other.class

      other_data = other.data # do not compute again
      data.each_with_index do |entry, index|
        other_entry = other_data[index]
        return false unless other_entry
        return false if entry[:key] != other_entry[:key]
        return false if entry[:value] != other_entry[:value]
      end

      true
    end

    # For objects of class Object, eql? is synonymous with ==:
    # http://ruby-doc.org/core-2.3.3/Object.html#method-i-eql-3F
    alias_method :eql?, :==

  private

    def replace_entry(old_entry)
      index = @data.index(old_entry)
      new_entry = { operation: :add }
      # insert the replacement to the same location
      @data.insert(index, new_entry)
      # the entry is not yet in the tree
      if old_entry[:operation] == :add
        key = old_entry[:key]
        @data.delete_if { |d| d[:key] == key }
      else
        old_entry[:operation] = :remove
      end

      new_entry
    end

    def mark_new_entry(new_entry, old_entry)
      # if an entry already exists then just modify it,
      # but only if we previously did not add it
      new_entry[:operation] = if old_entry && old_entry[:operation] != :add
                                :modify
                              else
                                :add
                              end
    end

    def entry_to_modify(key, value)
      entry = @data.find { |d| d[:key] == key }
      # we are switching from tree to value or treevalue to value only
      # like change from key=value to key=value#comment
      if entry && entry[:value].class != value.class
        entry = replace_entry(entry)
      end
      new_entry = entry || {}
      mark_new_entry(new_entry, entry)

      @data << new_entry unless entry

      new_entry
    end
  end

  # @example read, print, modify and serialize again
  #    require "cfa/augeas_parser"
  #
  #    parser = CFA::AugeasParser.new("Sysconfig.lns")
  #    parser.file_name = "/etc/default/grub" # for error reporting
  #    data = parser.parse(File.read("/etc/default/grub"))
  #
  #    puts data["GRUB_DISABLE_OS_PROBER"]
  #    data["GRUB_DISABLE_OS_PROBER"] = "true"
  #    puts parser.serialize(data)
  class AugeasParser
    # @return [String] optional, used for error reporting
    attr_accessor :file_name

    # @param lens [String] a lens name, like "Sysconfig.lns"
    def initialize(lens)
      @lens = lens
      @file_name = nil
    end

    # @param raw_string [String] a string to be parsed
    # @return [AugeasTree] the parsed data
    def parse(raw_string)
      require "cfa/augeas_parser/reader"
      # Workaround for augeas lenses that don't handle files
      # without a trailing newline (bsc#1064623, bsc#1074891, bsc#1080051
      # and gh#hercules-team/augeas#547)
      raw_string += "\n" unless raw_string.end_with?("\n")
      @old_content = raw_string

      # open augeas without any autoloading and it should not touch disk and
      # load lenses as needed only
      root = load_path = nil
      Augeas.open(root, load_path, Augeas::NO_MODL_AUTOLOAD) do |aug|
        aug.set("/input", raw_string)
        report_error(aug, :parsing, file_name, raw_string) \
          unless aug.text_store(@lens, "/input", "/store")

        return AugeasReader.read(aug, "/store")
      end
    end

    # @param data [AugeasTree] the data to be serialized
    # @return [String] a string to be written
    def serialize(data)
      require "cfa/augeas_parser/writer"
      # open augeas without any autoloading and it should not touch disk and
      # load lenses as needed only
      root = load_path = nil
      Augeas.open(root, load_path, Augeas::NO_MODL_AUTOLOAD) do |aug|
        aug.set("/input", @old_content || "")
        aug.text_store(@lens, "/input", "/store") if @old_content
        AugeasWriter.new(aug).write("/store", data)

        res = aug.text_retrieve(@lens, "/input", "/store", "/output")
        report_error(aug, :serializing, file_name, data) unless res

        return aug.get("/output")
      end
    end

    # @return [AugeasTree] an empty tree that can be filled
    #   for future serialization
    def empty
      AugeasTree.new
    end

  private

    # @param aug [::Augeas]
    # @param activity [:parsing, :serializing] for better error messages
    # @param file_name [String,nil] a file name
    # @param data [AugeasTree, String] used data so augeas tree for
    #   serializing or file content for parsing
    def report_error(aug, activity, file_name, data = nil)
      report_internal_error!(aug)

      file_name ||= "(unknown file)"
      args = aug_get_error(aug)
      args[:file] = file_name
      report_activity_error!(args, activity, data)
    end

    def report_internal_error!(aug)
      error = aug.error
      # zero is no error, so there's a problem in the lens
      return if error[:code].zero?

      raise AugeasInternalError.new(error[:message], error[:details])
    end

    def report_activity_error!(args, activity, data)
      case activity
      when :parsing
        args[:file_content] = data
        raise AugeasParsingError, args
      when :serializing
        args[:aug_tree] = data
        raise AugeasSerializingError, args
      else
        raise ArgumentError, "invalid activity #{activity.inspect}"
      end
    end

    def aug_get_error(aug)
      {
        message: aug.get("/augeas/text/store/error/message"),
        line:    aug.get("/augeas/text/store/error/line"),
        char:    aug.get("/augeas/text/store/error/char"), # column
        # file, line+column range, like
        # "/usr/share/augeas/lenses/dist/hosts.aug:23.12-.42:"
        lens:    aug.get("/augeas/text/store/error/lens")
      }
    end
  end
end