lib/cfa/augeas_parser/writer.rb
# frozen_string_literal: true
module CFA
# The goal of this class is to write the data stored in {AugeasTree}
# back to Augeas.
#
# It tries to make only the needed changes, as internally Augeas keeps
# a flag whether data has been modified,
# and keeps the unmodified parts of the file untouched.
#
# @note internal only, unstable API
# @api private
class AugeasWriter
# @param aug result of Augeas.create
def initialize(aug)
@aug = aug
end
# Writes the data in *tree* to a given *prefix* in Augeas
# @param prefix [String] where to write *tree* in Augeas
# @param tree [CFA::AugeasTree] tree to write
def write(prefix, tree, top_level: true)
@lazy_operations = LazyOperations.new(aug) if top_level
tree.all_data.each do |entry|
located_entry = LocatedEntry.new(tree, entry, prefix)
process_operation(located_entry)
end
@lazy_operations.run if top_level
end
private
# {AugeasElement} together with information about its location and a few
# helper methods to detect siblings.
#
# @example data for an already existing comment living under /main
# entry.orig_key # => "#comment[15]"
# entry.path # => "/main/#comment[15]"
# entry.key # => "#comment"
# entry.entry_tree # => AugeasTree.new
# entry.entry_value # => "old boring comment"
#
# @example data for a new comment under /main
# entry.orig_key # => nil
# entry.path # => nil
# entry.key # => "#comment"
# entry.entry_tree # => AugeasTree.new
# entry.entry_value # => "new boring comment"
#
# @example data for new tree placed at /main
# entry.orig_key # => "main"
# entry.path # => "/main"
# entry.key # => "main"
# entry.entry_tree # => entry[:value]
# entry.entry_value # => nil
#
class LocatedEntry
attr_reader :prefix
attr_reader :entry
attr_reader :tree
def initialize(tree, entry, prefix)
@tree = tree
@entry = entry
@prefix = prefix
detect_tree_value_modification
end
def orig_key
entry[:orig_key]
end
def path
return @path if @path
return nil unless orig_key
@path = @prefix + "/" + orig_key
end
def key
return @key if @key
@key = @entry[:key]
@key = @key[0..-3] if @key.end_with?("[]")
@key
end
# @return [LocatedEntry, nil]
# a preceding entry that already exists in the Augeas tree
# or nil if it does not exist.
def preceding_existing
preceding_entry = preceding_entries.reverse_each.find do |entry|
entry[:operation] != :add
end
return nil unless preceding_entry
LocatedEntry.new(tree, preceding_entry, prefix)
end
# @return [true, false] returns true if there is any following entry
# in the Augeas tree
def any_following?
following_entries.any? { |e| e[:operation] != :remove }
end
# @return [AugeasTree] the Augeas tree nested under this entry.
# If there is no such tree, it creates an empty one.
def entry_tree
value = entry[:value]
case value
when AugeasTree then value
when AugeasTreeValue then value.tree
else AugeasTree.new
end
end
# @return [String, nil] the Augeas value of this entry. Can be nil.
# If the value is an {AugeasTree} then return nil.
def entry_value
value = entry[:value]
case value
when AugeasTree then nil
when AugeasTreeValue then value.value
else value
end
end
private
# For {AugeasTreeValue} we have a problem with detection of
# value modification as it is enclosed in a diferent object.
# So propagate it to this entry here.
def detect_tree_value_modification
return unless entry[:value].is_a?(AugeasTreeValue)
return if entry[:operation] != :keep
entry[:operation] = entry[:value].modified? ? :modify : :keep
end
# the entries preceding this entry
def preceding_entries
return [] if index.zero? # first entry
tree.all_data[0..(index - 1)]
end
# the entries following this entry
def following_entries
tree.all_data[(index + 1)..-1]
end
# the index of this entry in its tree
def index
@index ||= tree.all_data.index(entry)
end
end
# Represents an operation that needs to be done after all modifications.
#
# The reason to have this class is that Augeas renumbers its arrays after
# some operations like `rm` or `insert` so previous paths are no longer
# valid. For this reason these sensitive operations that change paths need
# to be done at the end and with careful order.
# See https://www.redhat.com/archives/augeas-devel/2017-March/msg00002.html
#
# @note This class depends on ordered operations. So adding and removing
# entries has to be done in order how they are placed in tree.
class LazyOperations
# @param aug result of Augeas.create
def initialize(aug)
@aug = aug
@operations = []
end
def add(located_entry)
@operations << { type: :add, located_entry: located_entry }
end
def remove(located_entry)
@operations << { type: :remove, path: located_entry.path }
end
# starts all previously inserted operations
def run
# the reverse order is needed because if there are two operations
# one after another then the latter cannot affect the former
@operations.reverse_each do |operation|
case operation[:type]
when :remove then remove_entry(operation[:path])
when :add
located_entry = operation[:located_entry]
add_entry(located_entry)
else
raise "Invalid lazy operation #{operation.inspect}"
end
end
end
private
attr_reader :aug
# Removes entry from tree. If *path* does not exist, then tries if it
# has changed to a collection:
# If we remove and re-add a single key then because of the laziness
# Augeas will first see the addition, making a 2 member collection,
# so we need to remove "key[1]" instead of "key".
# @param path [String] original path name to remove
def remove_entry(path)
aug.rm(path_to_remove(path))
end
# Finds path to remove, as path can be meanwhile renumbered, see
# #remove_entry
def path_to_remove(path)
if aug.match(path).size == 1
path
elsif !aug.match(path + "[1]").empty?
path + "[1]"
else
raise "Unknown augeas path #{path}"
end
end
# Adds entry to tree. At first it finds where to add it to be in correct
# place and then sets its value. Recursive if needed. In recursive case
# it is already known that whole sub-tree is also new and just added.
def add_entry(located_entry)
path = insert_entry(located_entry)
set_new_value(path, located_entry)
end
# Sets new value to given path. It is used for values that are not yet in
# Augeas tree. If needed it does recursive adding.
# @param path [String] path which can contain Augeas path expression for
# key of new value
# @param located_entry [LocatedEntry] entry to write
# @see https://github.com/hercules-team/augeas/wiki/Path-expressions
def set_new_value(path, located_entry)
aug.set(path, located_entry.entry_value)
# we need to get new path as path used in aug.set can contains
# "[last() + 1]", so adding subtree to it, adds additional entry.
# So here, we replace "[last() + 1]" with "[last()]" so it will match
# path created by previous aug.set
match_str = path.gsub(/\[\s*last\(\)\s*\+\s*1\]/, "[last()]")
new_path = aug.match(match_str).first
add_subtree(located_entry.entry_tree, new_path)
end
# Adds new subtree. Simplified version of common write as it is known
# that all entries will be just added.
# @param tree [CFA::AugeasTree] to add
# @param prefix [String] prefix where to place *tree*
def add_subtree(tree, prefix)
tree.all_data.each do |entry|
located_entry = LocatedEntry.new(tree, entry, prefix)
# universal path that handles also new elements for arrays
path = "#{prefix}/#{located_entry.key}[last()+1]"
set_new_value(path, located_entry)
end
end
# It inserts a key at given position without setting its value.
# Its logic is to set it after the last valid entry. If it is not defined
# then tries to place it before the first valid entry in tree. If there is
# no entry in tree, then does not insert a position, which means that
# subsequent setting of value appends it to the end.
#
# @param located_entry [LocatedEntry] entry to insert
# @return [String] where value should be written. Can
# contain path expressions.
# See https://github.com/hercules-team/augeas/wiki/Path-expressions
def insert_entry(located_entry)
# entries with add not exist yet
preceding = located_entry.preceding_existing
prefix = located_entry.prefix
if preceding
insert_after(preceding, located_entry)
# entries with remove is already removed, otherwise find previously
elsif located_entry.any_following?
aug.insert(prefix + "/*[1]", located_entry.key, true)
aug.match(prefix + "/*[1]").first
else
"#{prefix}/#{located_entry.key}"
end
end
# Insert key after preceding.
# @see insert_entry
# @param preceding [LocatedEntry] entry after which the new one goes
# @param located_entry [LocatedEntry] entry to insert
# @return [String] where value should be written.
def insert_after(preceding, located_entry)
res = aug.insert(preceding.path, located_entry.key, false)
# if insert failed it means, that previous preceding entry was single
# element and now it is multiple ones, so try to first element as add
# is done in reverse order
if res == -1
# TODO: what about deep nesting of trees where upper level change
# from single to collection?
aug.insert(preceding.path + "[1]", located_entry.key, false)
end
path_after(preceding)
end
# Finds path immediately after preceding entry
# @param preceding [LocatedEntry]
def path_after(preceding)
paths = aug.match(preceding.prefix + "/*")
paths[find_preceding_index(paths, preceding) + 1]
end
def find_preceding_index(paths, preceding)
path = preceding.path
# common case, just included
return paths.index(path) if paths.include?(path)
# not found, so it means that some collection or single entry switch
new_path = +"/"
path.split("/").each do |element|
new_path << "/" unless new_path.end_with?("/")
new_path << pick_candidate(paths, new_path, element)
end
paths.index(new_path) ||
raise("Cannot find path #{preceding.path} in #{paths.inspect}")
end
# it returns variant of element that exists in path
def pick_candidate(paths, new_path, element)
# NOTE: order here is important due to early matching
candidates = [element + "/", element + "[1]",
element.sub(/\[\d+\]/, ""), element]
paths.each do |p|
candidates.each do |c|
return c if p.start_with?(new_path + c)
end
end
end
end
attr_reader :aug
# Does modification according to the operation defined in {AugeasElement}
# @param located_entry [LocatedEntry] entry to process
def process_operation(located_entry)
case located_entry.entry[:operation]
when :add, nil then @lazy_operations.add(located_entry)
when :remove then @lazy_operations.remove(located_entry)
when :modify then modify_entry(located_entry)
when :keep then recurse_write(located_entry)
else raise "invalid :operation in #{located_entry.inspect}"
end
end
# Writes value of entry to path and if it has a sub-tree
# then it calls {#write} on it
# @param located_entry [LocatedEntry] entry to modify
def modify_entry(located_entry)
value = located_entry.entry_value
aug.set(located_entry.path, value)
report_error { aug.set(located_entry.path, value) }
recurse_write(located_entry)
end
# calls write on entry if entry have sub-tree
# @param located_entry [LocatedEntry] entry to recursive write
def recurse_write(located_entry)
write(located_entry.path, located_entry.entry_tree, top_level: false)
end
# Calls block and if it failed, raise exception with details from augeas
# why it failed
# @yield call to aug that is secured
# @raise [RuntimeError]
def report_error
return if yield
error = aug.error
# zero is no error, so problem in lense
if aug.error[:code].nonzero?
raise "Augeas error #{error[:message]}. Details: #{error[:details]}."
end
msg = aug.get("/augeas/text/store/error/message")
location = aug.get("/augeas/text/store/error/lens")
raise "Augeas serializing error: #{msg} at #{location}"
end
end
end