diff --git a/README.md b/README.md index bd33c8db..8265c828 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,32 @@ As you may have noticed there are several ways how to specify arguments. Here's * values specified on CLI * interactive mode arguments +## Answer File Schema + +The answer file schema can be described using Puppet types as such: + +``` +Hash[ + String $puppet_class => Hash[ + String $parameter => Enum[true, false, Hash[String, Variant[String, Boolean, Integer, Array, Hash]]] + ] +] +``` + +An example of each available option: + +``` +class_a: true +class_b: false +class_c: {} +class_d: + key: value + key2: 'value' + key3: false + key4: 1 + key5: ['a', 'b'] +``` + ## Requirements Kafo is supported with Puppet versions 4.9+, 5 and 6. Puppet may be installed diff --git a/lib/kafo/answer_file.rb b/lib/kafo/answer_file.rb new file mode 100644 index 00000000..e0361516 --- /dev/null +++ b/lib/kafo/answer_file.rb @@ -0,0 +1,16 @@ +require 'kafo/answer_file/v1' + +module Kafo + module AnswerFile + + def self.load_answers(filename, version) + case version + when 1 + AnswerFile::V1.new(filename) + else + raise InvalidAnswerFileVersion + end + end + + end +end diff --git a/lib/kafo/answer_file/base.rb b/lib/kafo/answer_file/base.rb new file mode 100644 index 00000000..ad7243d0 --- /dev/null +++ b/lib/kafo/answer_file/base.rb @@ -0,0 +1,39 @@ +require 'yaml' + +module Kafo + module AnswerFile + class Base + + attr_reader :answers, :filename + + def initialize(filename) + @filename = filename + @answers = YAML.load_file(@filename) + validate + end + + def save(data, config_header) + FileUtils.touch @filename + File.chmod 0600, @filename + File.open(@filename, 'w') { |f| f.write(config_header + format(YAML.dump(data))) } + end + + def puppet_classes + raise NoMethodError + end + + def class_enabled?(puppet_class) + raise NoMethodError + end + + def parameters_for_class(puppet_class) + raise NoMethodError + end + + def validate + raise NoMethodError + end + + end + end +end diff --git a/lib/kafo/answer_file/v1.rb b/lib/kafo/answer_file/v1.rb new file mode 100644 index 00000000..b63af0dc --- /dev/null +++ b/lib/kafo/answer_file/v1.rb @@ -0,0 +1,33 @@ +require 'kafo/answer_file/base' + +module Kafo + module AnswerFile + class V1 < Base + + def puppet_classes + @answers.keys.sort + end + + def parameters_for_class(puppet_class) + params = @answers[puppet_class] + params.is_a?(Hash) ? params : {} + end + + def class_enabled?(puppet_class) + value = @answers[puppet_class.is_a?(String) ? puppet_class : puppet_class.identifier] + !!value || value.is_a?(Hash) + end + + def validate + invalid = @answers.reject do |puppet_class, value| + value.is_a?(Hash) || [true, false].include?(value) + end + + unless invalid.empty? + fail InvalidAnswerFile, "Answer file at #{@filename} has invalid values for #{invalid.keys.join(', ')}. Please ensure they are either a hash or true/false." + end + end + + end + end +end diff --git a/lib/kafo/configuration.rb b/lib/kafo/configuration.rb index d873b7bb..21781575 100644 --- a/lib/kafo/configuration.rb +++ b/lib/kafo/configuration.rb @@ -6,6 +6,7 @@ require 'kafo/data_type_parser' require 'kafo/execution_environment' require 'kafo/scenario_option' +require 'kafo/answer_file' module Kafo class Configuration @@ -43,6 +44,7 @@ class Configuration ScenarioOption::KAFO_MODULES_DIR => nil, ScenarioOption::CONFIG_HEADER_FILE => nil, ScenarioOption::DONT_SAVE_ANSWERS => nil, + ScenarioOption::ANSWER_FILE_VERSION => 1, } def self.get_scenario_id(filename) @@ -55,14 +57,24 @@ def initialize(file, persist = true) configure_application @logger = KafoConfigure.logger - @answer_file = app[:answer_file] begin - @data = load_yaml_file(@answer_file) + @answer_file = AnswerFile.load_answers(app[:answer_file], app[:answer_file_version] || 1) rescue Errno::ENOENT - puts "No answer file at #{@answer_file} found, can not continue" - KafoConfigure.exit(:no_answer_file) + KafoConfigure.exit(:no_answer_file) do + @logger.error "No answer file found at #{app[:answer_file]}" + end + rescue InvalidAnswerFileVersion + KafoConfigure.exit(:invalid_values) do + @logger.error "Unsupported answer file version" + end + rescue InvalidAnswerFile => error + KafoConfigure.exit(:invalid_values) do + @logger.error(error.message) + end end + @answers = @answer_file.answers + @config_dir = File.dirname(@config_file) @scenario_id = Configuration.get_scenario_id(@config_file) end @@ -95,7 +107,7 @@ def configure_application def app @app ||= begin begin - configuration = load_yaml_file(@config_file) + configuration = YAML.load_file(@config_file) rescue configuration = {} end @@ -130,7 +142,7 @@ def has_custom_fact?(key) def modules @modules ||= begin register_data_types - @data.keys.map { |mod| PuppetModule.new(mod, configuration: self).parse }.sort + @answer_file.puppet_classes.map { |mod| PuppetModule.new(mod, configuration: self).parse }.sort end end @@ -231,27 +243,22 @@ class { '::kafo_configure::dump_values': # if a value is a true we return empty hash because we have no specific options for a # particular puppet module - def [](key) - value = @data[key] - value.is_a?(Hash) ? value : {} + def [](puppet_class) + @answer_file.parameters_for_class(puppet_class) end - def module_enabled?(mod) - value = @data[mod.is_a?(String) ? mod : mod.identifier] - !!value || value.is_a?(Hash) + def module_enabled?(puppet_class) + @answer_file.class_enabled?(puppet_class) end def config_header - files = [app[:config_header_file], File.join(gem_root, '/config/config_header.txt')].compact - file = files.find { |f| File.exist?(f) } + files = [app[:config_header_file], File.join(gem_root, '/config/config_header.txt')].compact + file = files.find { |f| File.exist?(f) } @config_header ||= file.nil? ? '' : File.read(file) end - def store(data, file = nil) - filename = file || answer_file - FileUtils.touch filename - File.chmod 0600, filename - File.open(filename, 'w') { |f| f.write(config_header + format(YAML.dump(data))) } + def store(answers) + @answer_file.save(answers, config_header) end def params @@ -322,11 +329,11 @@ def answers def run_migrations migrations = Kafo::Migrations.new(migrations_dir) - @app, @data = migrations.run(app, answers) + @app, @answers = migrations.run(app, @answers) if migrations.migrations.count > 0 @modules = nil # force the lazy loaded modules to reload next time they are used save_configuration(app) - store(answers) + store(@answers) migrations.store_applied @logger.notice("#{migrations.migrations.count} migration/s were applied. Updated configuration was saved.") end @@ -376,10 +383,6 @@ def format(data) data.gsub('!ruby/sym ', ':') end - def load_yaml_file(filename) - YAML.load_file(filename) - end - # Loads YAML from mixed output, finding the "---" and "..." document start/end delimiters def load_yaml_from_output(lines) start = lines.find_index { |l| l.start_with?('---') } diff --git a/lib/kafo/exceptions.rb b/lib/kafo/exceptions.rb index 698babc2..ec0e933a 100644 --- a/lib/kafo/exceptions.rb +++ b/lib/kafo/exceptions.rb @@ -8,4 +8,10 @@ class ConditionError < StandardError class ParserError < StandardError end + + class InvalidAnswerFileVersion < StandardError + end + + class InvalidAnswerFile < StandardError + end end diff --git a/lib/kafo/kafo_configure.rb b/lib/kafo/kafo_configure.rb index 41389c75..2c8725d5 100644 --- a/lib/kafo/kafo_configure.rb +++ b/lib/kafo/kafo_configure.rb @@ -464,9 +464,9 @@ def argument_missing?(value) !!self.class.declared_options.find { |opt| opt.handles?(value) } end - def store_params(file = nil) + def store_params data = Hash[config.modules.map { |mod| [mod.identifier, mod.enabled? ? mod.params_hash : false] }] - config.store(data, file) + config.store(data) end def validate_all(logging = true) diff --git a/lib/kafo/scenario_option.rb b/lib/kafo/scenario_option.rb index b399d683..f34af176 100644 --- a/lib/kafo/scenario_option.rb +++ b/lib/kafo/scenario_option.rb @@ -12,6 +12,9 @@ class ScenarioOption # Path to answer file, if the file does not exist a $pwd/config/answers.yaml is used as a fallback ANSWER_FILE = :answer_file + # The version of the answer file schema being used + ANSWER_FILE_VERSION = :answer_file_version + # Enable colors? If you don't touch this, we'll autodetect terminal capabilities COLORS = :colors # Color scheme, we support :bright and :dark (first is better for white background, dark for black background) diff --git a/test/fixtures/answer_files/v1/basic-answers.yaml b/test/fixtures/answer_files/v1/basic-answers.yaml new file mode 100644 index 00000000..8f31128f --- /dev/null +++ b/test/fixtures/answer_files/v1/basic-answers.yaml @@ -0,0 +1,6 @@ +--- +class_a: true +class_b: + key: value +class_c: {} +class_d: false diff --git a/test/fixtures/answer_files/v1/invalid-answers.yaml b/test/fixtures/answer_files/v1/invalid-answers.yaml new file mode 100644 index 00000000..d0a41c42 --- /dev/null +++ b/test/fixtures/answer_files/v1/invalid-answers.yaml @@ -0,0 +1,4 @@ +--- +class_a: 'true' +class_b: +class_c: 1 diff --git a/test/kafo/answer_file_test.rb b/test/kafo/answer_file_test.rb new file mode 100644 index 00000000..73cb9c89 --- /dev/null +++ b/test/kafo/answer_file_test.rb @@ -0,0 +1,38 @@ +require 'test_helper' + +describe 'Kafo::AnswerFile' do + describe 'answer file version 1' do + describe 'valid answer file' do + let(:answer_file_path) { 'test/fixtures/answer_files/v1/basic-answers.yaml' } + let(:answer_file) { Kafo::AnswerFile.load_answers(answer_file_path, 1) } + + it 'returns the sorted puppet classes' do + _(answer_file.puppet_classes).must_equal(['class_a', 'class_b', 'class_c', 'class_d']) + end + + it 'returns the parameters for a class' do + _(answer_file.parameters_for_class('class_b')).must_equal({'key' => 'value'}) + end + + it 'returns true for a class with a hash' do + _(answer_file.class_enabled?('class_c')).must_equal(true) + end + + it 'returns true for a class set to true' do + _(answer_file.class_enabled?('class_a')).must_equal(true) + end + + it 'returns false for a class set to false' do + _(answer_file.class_enabled?('class_d')).must_equal(false) + end + end + + describe 'invalid answer file' do + let(:answer_file_path) { 'test/fixtures/answer_files/v1/invalid-answers.yaml' } + + it 'exits with invalid_answer_file' do + _(Proc.new { Kafo::AnswerFile.load_answers(answer_file_path, 1) } ).must_raise Kafo::InvalidAnswerFile + end + end + end +end diff --git a/test/kafo/configuration_test.rb b/test/kafo/configuration_test.rb index 85576c1c..46623b6b 100644 --- a/test/kafo/configuration_test.rb +++ b/test/kafo/configuration_test.rb @@ -213,5 +213,22 @@ module Kafo end end end + + describe 'invalid answer file' do + let(:answer_file_path) { 'test/fixtures/answer_files/v1/invalid-answers.yaml' } + let(:dummy_logger) { DummyLogger.new } + let(:config_file) { ConfigFileFactory.build('invalid-answer', {:answer_file => answer_file_path}.to_yaml).path } + + before do + Kafo::KafoConfigure.logger = dummy_logger + end + + it 'exits with invalid_answer_file' do + must_exit_with_code(21) { Kafo::Configuration.new(config_file, false) } + + dummy_logger.rewind + _(dummy_logger.error.read).must_match(%r{Answer file at\s.*\shas invalid values for class_a, class_b, class_c. Please ensure they are either a hash or true/false.\n}) + end + end end end