src/lib/y2configuration_management/salt/form.rb
# Copyright (c) [2018] SUSE LLC
#
# All Rights Reserved.
#
# This program is free software; you can redistribute it and/or modify it
# under the terms of version 2 of the GNU General Public License as published
# by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along
# with this program; if not, contact SUSE LLC.
#
# To contact SUSE LLC about this file by physical or electronic mail, you may
# find current contact information at www.suse.com.
require "yaml"
require "yast"
require "y2configuration_management/salt/form_condition"
require "y2configuration_management/salt/form_element_locator"
require "y2configuration_management/salt/form_element_factory"
require "y2configuration_management/salt/form_element_helpers"
require "y2configuration_management/salt/form_data_reader"
module Y2ConfigurationManagement
module Salt
# A [Form][1] for [Salt Formulas][2].
#
# This class offers an API on top of the form specification.
#
# [1]: https://www.suse.com/documentation/suse-manager-3/3.2/susemanager-best-practices/html/book.suma.best.practices/best.practice.salt.formulas.and.forms.html#best.practice.salt.formulas.pillar
# [2]: https://docs.saltstack.com/en/latest/topics/development/conventions/formulas.html
class Form
include Yast::Logger
# @return [Container]
attr_reader :root
# @return [Hash] The original specification (deserialized form.yml).
attr_reader :spec
# Constructor
#
# The original specification (deserialized form.yml).
#
# @param spec [Hash] The original specification (deserialized form.yml).
def initialize(spec)
@root = Container.new("root", spec, parent: nil)
@spec = spec
end
# Creates a new Form object reading the definition from a YAML file
#
# @param path [String] file path to read the form YAML definition
# @return [Form, nil]
def self.from_file(path)
return nil unless File.exist?(path)
definition = YAML.safe_load(File.read(path))
new(definition)
rescue IOError, SystemCallError, RuntimeError => error
log.error("Reading #{path} failed with exception: #{error.inspect}")
nil
end
# Recursively looks for a particular {FormElement}
#
# @example look for a FormElement by a specific name, locator or id
#
# f = Y2ConfigurationManagemenet.from_file("form.yml")
# f.find_element_by(name: "subnets") #=> <Collection @name="subnets">
# locator = FormElementLocator.from_string("root#dhcpd")
# f.find_element_by(locator: locator) #=> <Container @name="dhcpd">
# f.find_element_by(id: "hosts") #=> <Container @id="hosts">
#
# @param arg [Hash]
# @return [FormElement, nil]
def find_element_by(arg)
root.find_element_by(arg)
end
end
# Three different kind of elements:
#
# scalar values, groups and collections
class FormElement
include FormElementHelpers
# @return [String] the key for the pillar
attr_reader :id
# @return [Symbol]
attr_reader :type
# @return [FormElement]
attr_reader :parent
# @return [String] The user visible name ($name)
attr_reader :name
alias_method :label, :name
# @return [String]
attr_reader :help
# @return [Symbol] specify the level in which the value can be edited.
# Possible values are: system, group and readonly
attr_reader :scope
# @return [Boolean]
attr_reader :optional
# @return [FormCondition,nil]
attr_reader :visible_if
# Constructor
#
# @param id [String]
# @param spec [Hash] form element specification
def initialize(id, spec, parent:)
@id = id
@name = spec.fetch("$name", humanize(id))
@type = type_for(spec)
@help = spec["$help"] if spec ["$help"]
@scope = spec.fetch("$scope", "system").to_sym
@optional = !!spec["$optional"]
@parent = parent
@visible_if = FormCondition.parse(spec.fetch("$visibleIf", ""))
end
# Return the absolute locator of this form element in the actual form
#
# @return [FormElementLocator]
def locator
return FormElementLocator.new([id.to_sym]) if parent.nil?
return parent.locator if parent.is_a?(Collection)
parent.locator.join(id.to_sym)
end
# Determines whether the element can be omitted from the Pillar
#
# @return [Boolean] true if it can be omitted; false otherwise
def optional?
@optional
end
private
# "foo" -> "Foo"
# "suse--fancy_salt_test" -> "Suse Fancy Salt Test"
def humanize(s)
s.split(/[-_]/).reject(&:empty?).map(&:capitalize).join(" ")
end
# Returns the type for a given form element specification
#
# @param spec [Hash] Form element specification
# @return [Symbol] Form element type
def type_for(spec)
if spec["$type"] == "text" && spec.key?("$key") && form_elements_in(spec).size <= 1
:key_value
else
spec.fetch("$type", "text").to_sym
end
end
end
# Scalar value FormElement
class FormInput < FormElement
# @return [String] help text usually displayed in the input field
attr_reader :placeholder
# @return [Boolean, Integer, String, nil] default input value
attr_reader :default
# @return [Array<String>] a list of possible values for a select input
attr_reader :values
# @return [Object] value to use when the user did not specify one
attr_reader :if_empty
# Constructor
#
# @param id [String]
# @param spec [Hash] form element specification
# @param parent [FormElement]
def initialize(id, spec, parent:)
@values = spec["$values"] if spec["$values"]
@placeholder = spec["$placeholder"] if spec["$placeholder"]
@default = spec["$default"]
@if_empty = spec["$ifEmpty"] if spec["$ifEmpty"]
super
end
# Determines whether the input matches search criteria
#
# This method has been implemented here to keep FormElement classes API consistent.
#
# @param arg [Hash]
# @return [FormInput, nil]
# @see Form#find_element_by
def find_element_by(arg)
return self if arg.any? { |k, v| public_send(k) == v }
nil
end
# Determines whether the input is a collection key
#
# In hash based collections, there is an special attribute called `$key` whose value is used
# as collection index.
#
# @return [Boolean]
def collection_key?
id == "$key"
end
end
# Container Element
class Container < FormElement
# @return [Array<FormElement>]
attr_reader :elements
# Constructor
#
# @param id [String]
# @param spec [Hash] form element specification
# @param parent [FormElement]
def initialize(id, spec, parent:)
super
@elements = []
build_elements(spec)
end
# Recursively looks for a particular {FormElement}
#
# @param arg [Hash]
# @return [FormElement, nil]
# @see Form#find_element_by
def find_element_by(arg)
return self if arg.any? { |k, v| public_send(k) == v }
elements.each do |element|
return element if arg.any? { |k, v| element.public_send(k) == v }
if element.respond_to?(:find_element_by)
nested_element = element.find_element_by(arg)
return nested_element if nested_element
end
end
nil
end
# Returns default data
#
# @return [FormData]
def default_data
FormDataReader.new(self, default).form_data
end
# Default values for included elements
#
# @return [Hash]
def default
elements.reduce({}) { |a, e| a.merge(e.id => e.default) }
end
private
# @param spec [Hash] form element specification
def build_elements(spec)
form_elements_in(spec).each do |id, nested_spec|
@elements << FormElementFactory.build(id, nested_spec, parent: self)
end
end
end
# Defines a collection of {FormElement}s or {Container}s all of them based in
# the same prototype.
class Collection < FormElement
# @return [Integer] lowest number of elements that needs to be defined
attr_reader :min_items
# @return [Integer] highest number of elements that needs to be defined
attr_reader :max_items
# @return [String] name for the members of the collection
attr_reader :item_name
# list of elements (let's see if we promote it to a class)
# or children or whatever
attr_reader :prototype
# Default collection values
attr_reader :default
# Constructor
#
# @param id [String]
# @param spec [Hash] form element specification
# @param parent [FormElement]
def initialize(id, spec, parent:)
super
@item_name = spec["$itemName"] if spec["$itemName"]
@min_items = spec["$minItems"] if spec["$minItems"]
@max_items = spec["$maxItems"] if spec["$maxItems"]
@prototype = prototype_for(id, spec)
@default = spec.fetch("$default", [])
end
# Recursively looks for a particular {FormElement}
#
# @param arg [Hash]
# @return [FormElement, nil]
# @see Form#find_element_by
def find_element_by(arg)
return self if arg.any? { |k, v| public_send(k) == v }
Array(prototype).each do |element|
nested_element = element.find_element_by(arg)
return nested_element if nested_element
end
nil
end
# Determines whether the collection is indexed by a key (instead of a numeric index)
#
# @return [Boolean] true if the collection uses a key; false otherwise
def keyed?
return false if prototype.nil? || !prototype.respond_to?(:elements)
prototype.elements.any? { |e| e.respond_to?(:collection_key?) && e.collection_key? }
end
# Determines whether the collection has scalar values (with or without keys)
#
# @return [Boolean] `true` if it is an scalar collection
#
# @see simple_scalar?
# @see keyed_scalar?
def scalar?
prototype.is_a?(FormInput)
end
# Determines whether the collection is an scalar one without index
#
# @return [Boolean] true if the collection is an scalar one; false otherwise
def simple_scalar?
return false if prototype.nil?
scalar? && prototype.type != :key_value
end
# Determines whether the collection is a hash with scalar values
#
# @return [Boolean] true if the collection is a hash with scalar values; false otherwise
def keyed_scalar?
return false if prototype.nil?
scalar? && prototype.type == :key_value
end
# Returns default data
#
# @return [FormData]
def default_data
FormDataReader.new(self, default).form_data
end
# Returns the default value for the prototype
#
# @return [FormData]
def prototype_default_data
if keyed_scalar?
FormDataReader.new(self, prototype.default || value).form_data.first
elsif simple_scalar?
FormDataReader.new(self, [prototype.default]).form_data.first
else
FormDataReader.new(prototype, prototype.default).form_data
end
end
private
# Return a single or group of {FormElement}s based on the prototype given
# in the form specification
#
# @param id [String]
# @param spec [Hash] form element specification
def prototype_for(id, spec)
return unless spec["$prototype"]
if spec["$prototype"]["$type"] || spec["$prototype"].any? { |k, _v| !k.start_with?("$") }
form_element = FormElementFactory.build(id, spec["$prototype"], parent: self)
return form_element if [FormInput, Container].include?(form_element.class)
end
spec["$prototype"].select { |k, _v| !k.start_with?("$") }.map do |element_id, element_spec|
FormElementFactory.build(element_id, element_spec, parent: self)
end
end
end
end
end