diff --git a/lib/openvox-strings/hiera.rb b/lib/openvox-strings/hiera.rb new file mode 100644 index 00000000..152098b4 --- /dev/null +++ b/lib/openvox-strings/hiera.rb @@ -0,0 +1,146 @@ +# frozen_string_literal: true + +require 'yaml' + +module OpenvoxStrings + # Parser for Hiera configuration and data + class Hiera + attr_reader :hiera_config, :common_data + + # Initializes a Hiera parser for a given module path + # @param [String] module_path The path to the Puppet module root directory + def initialize(module_path) + @module_path = module_path + @hiera_config = load_hiera_config + @common_data = load_common_data + end + + # Checks if Hiera is configured for this module + # @return [Boolean] true if hiera.yaml exists and is valid + def hiera_enabled? + !@hiera_config.nil? + end + + # Gets the default value for a parameter from Hiera data + # @param [String] class_name The fully qualified class name (e.g., 'github_actions_runner') + # @param [String] param_name The parameter name + # @return [String, nil] The default value as a string, or nil if not found + def lookup_default(class_name, param_name) + return nil unless hiera_enabled? + return nil if @common_data.nil? + + # Try to lookup with class prefix: modulename::parametername + key = "#{class_name}::#{param_name}" + return nil unless @common_data.key?(key) + + value = @common_data[key] + + # Convert value to Puppet-compatible string representation + value_to_puppet_string(value) + end + + private + + # Loads and parses hiera.yaml from the module root + # @return [Hash, nil] The parsed hiera configuration, or nil if not found/invalid + def load_hiera_config + hiera_file = File.join(@module_path, 'hiera.yaml') + load_yaml_data(hiera_file) + end + + # Finds the path to the first static hierarchy layer (without interpolations) + # @return [String, nil] The full path to the data file, or nil if not found + def find_first_static_layer_path + return nil unless hiera_enabled? + + # Get datadir from hiera config (defaults to 'data') + datadir = @hiera_config.dig('defaults', 'datadir') || 'data' + + # Find first hierarchy entry without interpolations + hierarchy = @hiera_config['hierarchy'] + return nil unless hierarchy + + first_static = hierarchy.find do |entry| + path_or_paths = entry['path'] || entry['paths'] + next false unless path_or_paths + + # Check if path(s) contain interpolations like %{...} + case path_or_paths + when String + !path_or_paths.include?('%{') + when Array + path_or_paths.none? { |p| p.include?('%{') } + else + false + end + end + + return nil unless first_static + + # Get the path from the hierarchy entry + data_file_path = first_static['path'] || first_static['paths']&.first + return nil unless data_file_path + + # Build and return full path + File.join(@module_path, datadir, data_file_path) + end + + # Loads and parses a YAML data file + # @param [String] file_path The full path to the YAML file to load + # @return [Hash, nil] The parsed YAML data, or nil if not found/invalid + def load_yaml_data(file_path) + return nil unless File.exist?(file_path) + + begin + YAML.load_file(file_path) + rescue StandardError => e + YARD::Logger.instance.warn "Failed to parse #{File.basename(file_path)}: #{e.message}" + nil + end + end + + # Loads and parses the first static hierarchy layer (without interpolations) + # @return [Hash, nil] The parsed data, or nil if not found/invalid + def load_common_data + data_file = find_first_static_layer_path + return nil unless data_file + + load_yaml_data(data_file) + end + + # Converts a Ruby value to a Puppet-compatible string representation + # @param [Object] value The value to convert + # @return [String] The Puppet-compatible string representation + def value_to_puppet_string(value) + case value + when String + # Empty strings from YAML nil (~) should be undef + return 'undef' if value.empty? + + # Strings should be quoted + "'#{value}'" + when Integer, Float, TrueClass, FalseClass + # Numbers and booleans are unquoted/lowercase + value.to_s + when NilClass, :undef + # Puppet undef + 'undef' + when Hash + # Convert hash to Puppet hash syntax (no spaces to match code defaults format) + return '{}' if value.empty? + + pairs = value.map { |k, v| "'#{k}' => #{value_to_puppet_string(v)}" } + "{ #{pairs.join(', ')} }" + when Array + # Convert array to Puppet array syntax (no spaces to match code defaults format) + return '[]' if value.empty? + + elements = value.map { |v| value_to_puppet_string(v) } + "[#{elements.join(', ')}]" + else + # Fallback: convert to string and quote + "'#{value}'" + end + end + end +end diff --git a/lib/openvox-strings/markdown/base.rb b/lib/openvox-strings/markdown/base.rb index 7a85a287..4f00afd1 100644 --- a/lib/openvox-strings/markdown/base.rb +++ b/lib/openvox-strings/markdown/base.rb @@ -4,6 +4,7 @@ require 'openvox-strings/json' require 'openvox-strings/yard' require 'openvox-strings/markdown/helpers' +require 'openvox-strings/hiera' # Implements classes that make elements in a YARD::Registry hash easily accessible for template. module OpenvoxStrings::Markdown @@ -86,6 +87,7 @@ def initialize(registry, component_type) @type = component_type @registry = registry @tags = registry[:docstring][:tags] || [] + @hiera = initialize_hiera end # generate 1:1 tag methods @@ -174,7 +176,13 @@ def enums_for_param(parameter_name) # @return [Hash] any defaults found for the component def defaults - @registry[:defaults] unless @registry[:defaults].nil? + # Start with code defaults from the Puppet class + code_defaults = @registry[:defaults] || {} + + # Try to merge with Hiera defaults if available + merged_defaults = merge_hiera_defaults(code_defaults) + + merged_defaults.empty? ? nil : merged_defaults end # @return [Hash] information needed for the table of contents @@ -216,6 +224,59 @@ def render(template) private + # Initializes Hiera integration for this component + # @return [OpenvoxStrings::Hiera, nil] Hiera instance or nil if not available + def initialize_hiera + return nil unless @registry[:file] + + # Find the module root directory from the file path + # Puppet modules have manifests/, lib/, data/ etc. at the root + module_path = find_module_root(@registry[:file]) + return nil unless module_path + + OpenvoxStrings::Hiera.new(module_path) + rescue StandardError => e + YARD::Logger.instance.debug "Failed to initialize Hiera: #{e.message}" + nil + end + + # Finds the module root directory from a file path + # @param [String] file_path The path to a file in the module + # @return [String, nil] The module root path or nil if not found + def find_module_root(file_path) + names = %w[metadata.json manifests hiera.yaml] + Pathname.new(file_path).expand_path.parent.ascend do |current_path| + names.each do |name| + return current_path if (current_path / name).exist? + end + end + end + + # Merges code defaults with Hiera defaults + # @param [Hash] code_defaults The defaults from the Puppet code + # @return [Hash] Merged defaults with code defaults taking precedence + def merge_hiera_defaults(code_defaults) + return code_defaults unless @hiera&.hiera_enabled? + + # Start with Hiera defaults + merged = {} + + # Get all parameters from the docstring + param_tags = @tags.select { |tag| tag[:tag_name] == 'param' } + + param_tags.each do |param_tag| + param_name = param_tag[:name] + next unless param_name + + # Try to get default from Hiera + hiera_default = @hiera.lookup_default(name, param_name) + merged[param_name] = hiera_default if hiera_default + end + + # Code defaults override Hiera defaults + merged.merge(code_defaults) + end + def select_tags(name) tags = @tags.select { |tag| tag[:tag_name] == name } tags.empty? ? nil : tags diff --git a/spec/unit/openvox-strings/hiera_spec.rb b/spec/unit/openvox-strings/hiera_spec.rb new file mode 100644 index 00000000..c3ab7f11 --- /dev/null +++ b/spec/unit/openvox-strings/hiera_spec.rb @@ -0,0 +1,428 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'openvox-strings/hiera' +require 'tmpdir' +require 'fileutils' + +describe OpenvoxStrings::Hiera do + let(:temp_dir) { Dir.mktmpdir } + + after do + FileUtils.remove_entry(temp_dir) + end + + describe '#initialize' do + context 'when hiera.yaml exists' do + before do + File.write(File.join(temp_dir, 'hiera.yaml'), <<~YAML) + version: 5 + defaults: + datadir: data + data_hash: yaml_data + hierarchy: + - name: "Common defaults" + path: "common.yaml" + YAML + end + + it 'loads hiera configuration' do + hiera = described_class.new(temp_dir) + expect(hiera.hiera_config).not_to be_nil + expect(hiera.hiera_config['version']).to eq(5) + end + + it 'is enabled' do + hiera = described_class.new(temp_dir) + expect(hiera).to be_hiera_enabled + end + end + + context 'when hiera.yaml does not exist' do + it 'is not enabled' do + hiera = described_class.new(temp_dir) + expect(hiera).not_to be_hiera_enabled + end + end + end + + describe '#find_first_static_layer_path' do + context 'when a static layer exists' do + before do + File.write(File.join(temp_dir, 'hiera.yaml'), <<~YAML) + version: 5 + defaults: + datadir: data + data_hash: yaml_data + hierarchy: + - name: "Common defaults" + path: "common.yaml" + YAML + end + + it 'returns the full path to the static layer' do + hiera = described_class.new(temp_dir) + result = hiera.send(:find_first_static_layer_path) + expect(result).to eq(File.join(temp_dir, 'data', 'common.yaml')) + end + end + + context 'when hierarchy contains interpolations before static layer' do + before do + # rubocop:disable Style/FormatStringToken + File.write(File.join(temp_dir, 'hiera.yaml'), <<~YAML) + version: 5 + defaults: + datadir: data + data_hash: yaml_data + hierarchy: + - name: "Per-node data" + path: "nodes/%{facts.hostname}.yaml" + - name: "Per-environment data" + path: "env/%{facts.environment}.yaml" + - name: "Common defaults" + path: "common.yaml" + YAML + # rubocop:enable Style/FormatStringToken + end + + it 'skips layers with interpolations and returns first static layer' do + hiera = described_class.new(temp_dir) + result = hiera.send(:find_first_static_layer_path) + expect(result).to eq(File.join(temp_dir, 'data', 'common.yaml')) + end + end + + context 'when only interpolated layers exist' do + before do + # rubocop:disable Style/FormatStringToken + File.write(File.join(temp_dir, 'hiera.yaml'), <<~YAML) + version: 5 + defaults: + datadir: data + data_hash: yaml_data + hierarchy: + - name: "Per-node data" + path: "nodes/%{facts.hostname}.yaml" + - name: "Per-environment data" + path: "env/%{environment}.yaml" + YAML + # rubocop:enable Style/FormatStringToken + end + + it 'returns nil' do + hiera = described_class.new(temp_dir) + result = hiera.send(:find_first_static_layer_path) + expect(result).to be_nil + end + end + + context 'when using custom datadir' do + before do + File.write(File.join(temp_dir, 'hiera.yaml'), <<~YAML) + version: 5 + defaults: + datadir: hieradata + data_hash: yaml_data + hierarchy: + - name: "Common" + path: "common.yaml" + YAML + end + + it 'uses the custom datadir' do + hiera = described_class.new(temp_dir) + result = hiera.send(:find_first_static_layer_path) + expect(result).to eq(File.join(temp_dir, 'hieradata', 'common.yaml')) + end + end + + context 'when hierarchy uses paths instead of path' do + before do + File.write(File.join(temp_dir, 'hiera.yaml'), <<~YAML) + version: 5 + defaults: + datadir: data + data_hash: yaml_data + hierarchy: + - name: "Multiple paths" + paths: + - "first.yaml" + - "second.yaml" + YAML + end + + it 'uses the first path from paths array' do + hiera = described_class.new(temp_dir) + result = hiera.send(:find_first_static_layer_path) + expect(result).to eq(File.join(temp_dir, 'data', 'first.yaml')) + end + end + + context 'when hierarchy uses paths array with mixed static and interpolated entries' do + before do + # rubocop:disable Style/FormatStringToken + File.write(File.join(temp_dir, 'hiera.yaml'), <<~YAML) + version: 5 + defaults: + datadir: data + data_hash: yaml_data + hierarchy: + - name: "Mixed paths" + paths: + - "common.yaml" + - "nodes/%{facts.hostname}.yaml" + - name: "Fallback" + path: "fallback.yaml" + YAML + # rubocop:enable Style/FormatStringToken + end + + it 'skips paths array containing any interpolations and uses next static layer' do + hiera = described_class.new(temp_dir) + result = hiera.send(:find_first_static_layer_path) + # Should skip "Mixed paths" because it contains interpolated entries + # and return "fallback.yaml" instead + expect(result).to eq(File.join(temp_dir, 'data', 'fallback.yaml')) + end + end + + context 'when hierarchy is empty' do + before do + File.write(File.join(temp_dir, 'hiera.yaml'), <<~YAML) + version: 5 + defaults: + datadir: data + data_hash: yaml_data + hierarchy: [] + YAML + end + + it 'returns nil' do + hiera = described_class.new(temp_dir) + result = hiera.send(:find_first_static_layer_path) + expect(result).to be_nil + end + end + end + + describe '#load_yaml_data' do + context 'when file exists and is valid YAML' do + before do + FileUtils.mkdir_p(File.join(temp_dir, 'data')) + File.write(File.join(temp_dir, 'data', 'test.yaml'), <<~YAML) + key1: value1 + key2: 42 + YAML + end + + it 'loads and parses the YAML file' do + hiera = described_class.new(temp_dir) + file_path = File.join(temp_dir, 'data', 'test.yaml') + result = hiera.send(:load_yaml_data, file_path) + expect(result).not_to be_nil + expect(result['key1']).to eq('value1') + expect(result['key2']).to eq(42) + end + end + + context 'when file does not exist' do + it 'returns nil' do + hiera = described_class.new(temp_dir) + result = hiera.send(:load_yaml_data, File.join(temp_dir, 'nonexistent.yaml')) + expect(result).to be_nil + end + end + + context 'when file contains invalid YAML' do + before do + FileUtils.mkdir_p(File.join(temp_dir, 'data')) + File.write(File.join(temp_dir, 'data', 'invalid.yaml'), 'invalid: yaml: content: [') + end + + it 'returns nil and logs warning' do + hiera = described_class.new(temp_dir) + file_path = File.join(temp_dir, 'data', 'invalid.yaml') + result = hiera.send(:load_yaml_data, file_path) + expect(result).to be_nil + end + end + end + + describe '#load_common_data' do + context 'when common.yaml exists' do + before do + File.write(File.join(temp_dir, 'hiera.yaml'), <<~YAML) + version: 5 + defaults: + datadir: data + data_hash: yaml_data + hierarchy: + - name: "Common defaults" + path: "common.yaml" + YAML + + FileUtils.mkdir_p(File.join(temp_dir, 'data')) + File.write(File.join(temp_dir, 'data', 'common.yaml'), <<~YAML) + testmodule::param1: 'value1' + testmodule::param2: 42 + testmodule::param3: true + testmodule::param4: ~ + YAML + end + + it 'loads common data' do + hiera = described_class.new(temp_dir) + expect(hiera.common_data).not_to be_nil + expect(hiera.common_data['testmodule::param1']).to eq('value1') + end + end + end + + describe '#lookup_default' do + before do + File.write(File.join(temp_dir, 'hiera.yaml'), <<~YAML) + version: 5 + defaults: + datadir: data + data_hash: yaml_data + hierarchy: + - name: "Common defaults" + path: "common.yaml" + YAML + + FileUtils.mkdir_p(File.join(temp_dir, 'data')) + File.write(File.join(temp_dir, 'data', 'common.yaml'), <<~YAML) + circus::tent_size: 'large' + circus::location: '/opt/circus-ground' + circus::season: '2025' + circus::ringmaster: 'Giovanni' + circus::has_animals: false + circus::performers: {} + circus::insurance_policy: ~ + YAML + end + + let(:hiera) { described_class.new(temp_dir) } + + it 'returns string values with quotes' do + result = hiera.lookup_default('circus', 'tent_size') + expect(result).to eq("'large'") + end + + it 'returns string paths with quotes' do + result = hiera.lookup_default('circus', 'location') + expect(result).to eq("'/opt/circus-ground'") + end + + it 'returns string version numbers with quotes' do + result = hiera.lookup_default('circus', 'season') + expect(result).to eq("'2025'") + end + + it 'returns string names with quotes' do + result = hiera.lookup_default('circus', 'ringmaster') + expect(result).to eq("'Giovanni'") + end + + it 'returns boolean false as false' do + result = hiera.lookup_default('circus', 'has_animals') + expect(result).to eq('false') + end + + it 'returns empty hash as {}' do + result = hiera.lookup_default('circus', 'performers') + expect(result).to eq('{}') + end + + it 'returns nil/undef as undef' do + result = hiera.lookup_default('circus', 'insurance_policy') + expect(result).to eq('undef') + end + + it 'returns nil for non-existent keys' do + result = hiera.lookup_default('circus', 'nonexistent') + expect(result).to be_nil + end + + it 'returns nil for wrong class name' do + result = hiera.lookup_default('wrong_module', 'tent_size') + expect(result).to be_nil + end + end + + describe '#value_to_puppet_string' do + let(:hiera) { described_class.new(temp_dir) } + + it 'converts strings with quotes' do + expect(hiera.send(:value_to_puppet_string, 'hello')).to eq("'hello'") + end + + it 'converts integers without quotes' do + expect(hiera.send(:value_to_puppet_string, 42)).to eq('42') + end + + it 'converts floats without quotes' do + expect(hiera.send(:value_to_puppet_string, 3.14)).to eq('3.14') + end + + it 'converts true to lowercase true' do + expect(hiera.send(:value_to_puppet_string, true)).to eq('true') + end + + it 'converts false to lowercase false' do + expect(hiera.send(:value_to_puppet_string, false)).to eq('false') + end + + it 'converts nil to undef' do + expect(hiera.send(:value_to_puppet_string, nil)).to eq('undef') + end + + it 'converts empty arrays' do + expect(hiera.send(:value_to_puppet_string, [])).to eq('[]') + end + + it 'converts arrays with values' do + expect(hiera.send(:value_to_puppet_string, %w[a b])).to eq("['a', 'b']") + end + + it 'converts empty hashes' do + expect(hiera.send(:value_to_puppet_string, {})).to eq('{}') + end + + it 'converts hashes with values' do + result = hiera.send(:value_to_puppet_string, { 'key' => 'value' }) + expect(result).to eq("{ 'key' => 'value' }") + end + + it 'converts nested structures' do + nested = { 'array' => [1, 2], 'nested_hash' => { 'key' => 'value' } } + result = hiera.send(:value_to_puppet_string, nested) + expect(result).to include("'array' => [1, 2]") + expect(result).to include("'nested_hash' => { 'key' => 'value' }") + end + + it 'formats arrays without spaces to match code defaults' do + # Arrays must use ['a', 'b'] not [ 'a', 'b' ] to match puppet-strings code format + result = hiera.send(:value_to_puppet_string, %w[alpha beta gamma]) + expect(result).to eq("['alpha', 'beta', 'gamma']") + expect(result).not_to include('[ ') + expect(result).not_to include(' ]') + end + + it 'formats integer arrays without spaces to match code defaults' do + result = hiera.send(:value_to_puppet_string, [1, 2, 3, 5, 8]) + expect(result).to eq('[1, 2, 3, 5, 8]') + expect(result).not_to include('[ ') + expect(result).not_to include(' ]') + end + + it 'formats nested arrays in hashes without spaces' do + nested = { 'tags' => %w[prod eu] } + result = hiera.send(:value_to_puppet_string, nested) + expect(result).to eq("{ 'tags' => ['prod', 'eu'] }") + expect(result).not_to include('[ ') + expect(result).not_to include(' ]') + end + end +end