module RspecPuppetFacts

The purpose of this module is to simplify the Puppet module’s RSpec tests by looping through all supported OS’es and their facts data which is received from the FacterDB.

Constants

FACTS_CACHE

Public Class Methods

augeas?() click to toggle source

Determine if the Augeas gem is available. @api private @return [Boolean] true if the augeas gem could be loaded. :nocov:

# File lib/rspec-puppet-facts.rb, line 275
def self.augeas?
  require 'augeas'
  true
rescue LoadError
  false
end
common_facts() click to toggle source

These facts are common for all OS’es and will be added to the facts retrieved from the FacterDB @api private @return [Hash <Symbol => String>]

# File lib/rspec-puppet-facts.rb, line 252
def self.common_facts
  return @common_facts if @common_facts

  @common_facts = {
    puppetversion: Puppet.version,
    rubysitedir: RbConfig::CONFIG['sitelibdir'],
    rubyversion: RUBY_VERSION,
  }

  @common_facts[:mco_version] = MCollective::VERSION if mcollective?

  if augeas?
    @common_facts[:augeasversion] = Augeas.open(nil, nil, Augeas::NO_MODL_AUTOLOAD).get('/augeas/version')
  end
  @common_facts = stringify_keys(@common_facts) if RSpec.configuration.facterdb_string_keys

  @common_facts
end
custom_facts() click to toggle source

Get custom facts @return [nil,Hash] @api private

# File lib/rspec-puppet-facts.rb, line 227
def self.custom_facts
  @custom_facts
end
facter_version_for_puppet_version(puppet_version) click to toggle source
# File lib/rspec-puppet-facts.rb, line 391
def self.facter_version_for_puppet_version(puppet_version)
  return Facter.version if puppet_version.nil?

  json_path = File.expand_path(File.join(__dir__, '..', 'ext', 'puppet_agent_components.json'))
  unless File.file?(json_path) && File.readable?(json_path)
    warning "#{json_path} does not exist or is not readable, defaulting to Facter #{Facter.version}"
    return Facter.version
  end

  fd = File.open(json_path, 'rb:UTF-8')
  data = JSON.parse(fd.read)

  version_map = data.map do |_, versions|
    if versions['puppet'].nil? || versions['facter'].nil?
      nil
    else
      [Gem::Version.new(versions['puppet']), versions['facter']]
    end
  end.compact

  puppet_gem_version = Gem::Version.new(puppet_version)
  applicable_versions = version_map.select { |p, _| puppet_gem_version >= p }
  if applicable_versions.empty?
    warning "Unable to find Puppet #{puppet_version} in #{json_path}, defaulting to Facter #{Facter.version}"
    return Facter.version
  end

  applicable_versions.max_by { |p, _| p }.last
rescue JSON::ParserError
  warning "#{json_path} contains invalid JSON, defaulting to Facter #{Facter.version}"
  Facter.version
ensure
  fd.close if fd
end
facter_version_to_loose_requirement(version) click to toggle source

Construct the loose facter version requirement @return [Optional] The version requirement to match @api private

# File lib/rspec-puppet-facts.rb, line 370
def self.facter_version_to_loose_requirement(version)
  string = facter_version_to_loose_requirement_string(version)
  Gem::Requirement.new(string) if string
end
facter_version_to_loose_requirement_string(version) click to toggle source

Construct the facter version requirement string @return [String] The version requirement to match @api private

# File lib/rspec-puppet-facts.rb, line 378
def self.facter_version_to_loose_requirement_string(version)
  if (m = /\A(?<major>[0-9]+)\.(?<minor>[0-9]+)(?:\.(?<patch>[0-9]+))?\Z/.match(version))
    # Interpret 3.1 as < 3.2 and 3.2.1 as < 3.3
    "< #{m[:major]}.#{m[:minor].to_i + 1}"
  elsif /\A[0-9]+\Z/.match?(version)
    # Interpret 3 as < 4
    "< #{version.to_i + 1}"
  else # rubocop:disable Style/EmptyElse
    # This would be the same as the strict requirement
    nil
  end
end
facter_version_to_strict_requirement(version) click to toggle source

Construct the strict facter version requirement @return [Gem::Requirement] The version requirement to match @api private

# File lib/rspec-puppet-facts.rb, line 351
def self.facter_version_to_strict_requirement(version)
  Gem::Requirement.new(facter_version_to_strict_requirement_string(version))
end
facter_version_to_strict_requirement_string(version) click to toggle source

Construct the strict facter version requirement string @return [String] The version requirement to match @api private

# File lib/rspec-puppet-facts.rb, line 358
def self.facter_version_to_strict_requirement_string(version)
  if /\A[0-9]+(\.[0-9]+)*\Z/.match?(version)
    # Interpret 3 as ~> 3.0
    "~> #{version}.0"
  else
    version
  end
end
mcollective?() click to toggle source

Determine if the mcollective gem is available @api private @return [Boolean] true if the mcollective gem could be loaded. :nocov:

# File lib/rspec-puppet-facts.rb, line 287
def self.mcollective?
  require 'mcollective'
  true
rescue LoadError
  false
end
meta_supported_os() click to toggle source

Get the “operatingsystem_support” structure from the parsed metadata.json file @raise [StandardError] if there is no “operatingsystem_support” in the metadata @return [Array<Hash>] @api private

# File lib/rspec-puppet-facts.rb, line 301
def self.meta_supported_os
  unless metadata['operatingsystem_support'].is_a? Array
    raise StandardError, 'Unknown operatingsystem support in the metadata file!'
  end

  metadata['operatingsystem_support']
end
metadata() click to toggle source

Read the metadata file and parse its JSON content. @raise [StandardError] if the metadata file is missing @return [Hash] @api private

# File lib/rspec-puppet-facts.rb, line 314
def self.metadata
  return @metadata if @metadata
  unless File.file? metadata_file
    raise StandardError, "Can't find metadata.json... dunno why"
  end

  content = File.read metadata_file
  @metadata = JSON.parse content
end
metadata_file() click to toggle source

This file contains the Puppet module’s metadata @return [String] @api private

# File lib/rspec-puppet-facts.rb, line 327
def self.metadata_file
  'metadata.json'
end
register_custom_fact(name, value, options) click to toggle source

Adds a custom fact to the @custom_facts variable.

@param [String] name Fact name @param [String,Proc] value Fact value. If proc, takes 2 params: os and facts hash @param [Hash] opts @option opts [String,Array<String>] :confine The applicable OS’s @option opts [String,Array<String>] :exclude OS’s to exclude @api private

# File lib/rspec-puppet-facts.rb, line 193
def self.register_custom_fact(name, value, options)
  @custom_facts ||= {}
  name = RSpec.configuration.facterdb_string_keys ? name.to_s : name.to_sym
  @custom_facts[name] = { options: options, value: value }
end
reset() click to toggle source

Reset the memoization to make the saved structures be generated again @api private

# File lib/rspec-puppet-facts.rb, line 342
def self.reset
  @custom_facts = nil
  @common_facts = nil
  @metadata = nil
end
spec_facts_os_filter() click to toggle source

If provided this filter can be used to limit the set of retrieved facts only to the matched OS names. The value is being taken from the SPEC_FACTS_OS environment variable and @return [nil,String] @api private

# File lib/rspec-puppet-facts.rb, line 237
def self.spec_facts_os_filter
  ENV.fetch('SPEC_FACTS_OS', nil)
end
spec_facts_strict?() click to toggle source

If SPEC_FACTS_STRICT is set to ‘yes`, RspecPuppetFacts will error on missing FacterDB entries, instead of warning & skipping the tests, or using an older facter version. @return [Boolean] @api private

# File lib/rspec-puppet-facts.rb, line 244
def self.spec_facts_strict?
  ENV['SPEC_FACTS_STRICT'] == 'yes'
end
warning(message) click to toggle source

Print a warning message to the console @param message [String] @api private

# File lib/rspec-puppet-facts.rb, line 334
def self.warning(message)
  warn message
end
with_custom_facts(os, facts) click to toggle source

Adds any custom facts according to the rules defined for the operating system with the given facts. @param [String] os Name of the operating system @param [Hash] facts Facts hash @return [Hash] facts Facts hash with custom facts added @api private

# File lib/rspec-puppet-facts.rb, line 205
def self.with_custom_facts(os, facts)
  return facts unless @custom_facts

  @custom_facts.each do |name, fact|
    next if fact[:options][:confine] && !fact[:options][:confine].include?(os)
    next if fact[:options][:exclude] && fact[:options][:exclude].include?(os)

    value = fact[:value].respond_to?(:call) ? fact[:value].call(os, facts) : fact[:value]
    # if merge_facts passed, merge supplied facts into facts hash
    if fact[:options][:merge_facts]
      facts.deep_merge!({ name => value })
    else
      facts[name] = value
    end
  end

  facts
end

Public Instance Methods

add_custom_fact(name, value, options = {}) click to toggle source

Register a custom fact that will be included in the facts hash. If it should be limited to a particular OS, pass a :confine option that contains the operating system(s) to confine to. If it should be excluded on a particular OS, use :exclude.

@param [String] name Fact name @param [String,Proc] value Fact value. If proc, takes 2 params: os and facts hash @param [Hash] opts @option opts [String,Array<String>] :confine The applicable OS’s @option opts [String,Array<String>] :exclude OS’s to exclude

# File lib/rspec-puppet-facts.rb, line 178
def add_custom_fact(name, value, options = {})
  options[:confine] = [options[:confine]] if options[:confine].is_a?(String)
  options[:exclude] = [options[:exclude]] if options[:exclude].is_a?(String)

  RspecPuppetFacts.register_custom_fact(name, value, options)
end
on_supported_os(opts = {}) click to toggle source

Use the provided options or the data from the metadata.json file to find a set of matching facts in the FacterDB. OS names and facts can be used in the Puppet RSpec tests to run the examples against all supported facts combinations.

The list of received OS facts can also be filtered by the SPEC_FACTS_OS environment variable. For example, if the variable is set to “debian” only the OS names which start with “debian” will be returned. It allows a user to quickly run the tests only on a single facts set without any file modifications.

@return [Hash <String => Hash>] @param [Hash] opts @option opts [String,Array<String>] :hardwaremodels The OS architecture names, i.e. x86_64 @option opts [Array<Hash>] :supported_os If this options is provided the data @option opts [String] :facterversion the facter version of which to select facts from, e.g.: ‘3.6’ will be used instead of the “operatingsystem_support” section if the metadata file even if the file is missing.

# File lib/rspec-puppet-facts.rb, line 32
def on_supported_os(opts = {})
  opts[:hardwaremodels] ||= ['x86_64']
  opts[:hardwaremodels] = [opts[:hardwaremodels]] unless opts[:hardwaremodels].is_a? Array
  opts[:supported_os] ||= RspecPuppetFacts.meta_supported_os
  opts[:facterversion] ||= RSpec.configuration.default_facter_version

  # This should list all variables that on_supported_os_implementation uses
  cache_key = [
    opts.to_s,
    RspecPuppetFacts.custom_facts.to_s,
    RspecPuppetFacts.spec_facts_os_filter,
    RspecPuppetFacts.spec_facts_strict?,
  ]

  result = FACTS_CACHE[cache_key] ||= on_supported_os_implementation(opts)

  # Marshalling is used to get unique instances which is needed for test
  # isolation when facts are overridden.
  Marshal.load(Marshal.dump(result))
end
on_supported_os_implementation(opts = {}) click to toggle source

The real implementation of on_supported_os.

Generating facts is slow - this allows memoization of the facts between multiple calls.

@api private

# File lib/rspec-puppet-facts.rb, line 59
def on_supported_os_implementation(opts = {})
  unless /\A\d+\.\d+(?:\.\d+)*\z/.match?((facterversion = opts[:facterversion]))
    raise ArgumentError, ":facterversion must be in the format 'n.n' or 'n.n.n' (n is numeric), not '#{facterversion}'"
  end

  filter = []
  opts[:supported_os].map do |os_sup|
    if os_sup['operatingsystemrelease']
      Array(os_sup['operatingsystemrelease']).map do |operatingsystemmajrelease|
        opts[:hardwaremodels].each do |hardwaremodel|
          os_release_filter = "/^#{Regexp.escape(operatingsystemmajrelease.split(' ')[0])}/"
          case os_sup['operatingsystem']
          when /BSD/i
            hardwaremodel = 'amd64'
          when /Solaris/i
            hardwaremodel = 'i86pc'
          when /AIX/i
            hardwaremodel = '/^IBM,.*/'
            os_release_filter = if operatingsystemmajrelease =~ /\A(\d+)\.(\d+)\Z/
                                  "/^#{$~[1]}#{$~[2]}00-/"
                                else
                                  "/^#{operatingsystemmajrelease}-/"
                                end
          when /Windows/i
            hardwaremodel = 'x86_64'
            os_sup['operatingsystem'] = os_sup['operatingsystem'].downcase
            operatingsystemmajrelease = operatingsystemmajrelease[/\A(?:Server )?(.+)/i, 1]

            # force quoting because windows releases can contain spaces
            os_release_filter = "\"#{operatingsystemmajrelease}\""
          when /Amazon/i
            # Tighten the regex for Amazon Linux 2 so that we don't pick up Amazon Linux 2016 or 2017 facts
            os_release_filter = '/^2$/' if operatingsystemmajrelease == '2'
          end

          filter << {
            'os.name' => os_sup['operatingsystem'],
            'os.release.full' => os_release_filter,
            'os.hardware' => hardwaremodel,
          }
        end
      end
    else
      opts[:hardwaremodels].each do |hardwaremodel|
        filter << {
          'os.name' => os_sup['operatingsystem'],
          'os.hardware' => hardwaremodel,
        }
      end
    end
  end

  strict_requirement = RspecPuppetFacts.facter_version_to_strict_requirement(facterversion)

  loose_requirement = RspecPuppetFacts.facter_version_to_loose_requirement(facterversion)
  received_facts = []

  # FacterDB may have newer versions of facter data for which it contains a subset of all possible
  # facter data (see FacterDB 0.5.2 for Facter releases 3.8 and 3.9). In this situation we need to
  # cycle through and downgrade Facter versions per platform type until we find matching Facter data.
  facterversion_key = RSpec.configuration.facterdb_string_keys ? 'facterversion' : :facterversion
  filter.each do |filter_spec|
    versions = FacterDB.get_facts(filter_spec, symbolize_keys: !RSpec.configuration.facterdb_string_keys).to_h do |facts|
      [Gem::Version.new(facts[facterversion_key]), facts]
    end

    version, facts = versions.select { |v, _f| strict_requirement =~ v }.max_by { |v, _f| v }

    unless version
      version, facts = versions.select { |v, _f| loose_requirement =~ v }.max_by { |v, _f| v } if loose_requirement
      next unless version

      raise ArgumentError, "No facts were found in the FacterDB for Facter v#{facterversion} on #{filter_spec}, aborting" if RspecPuppetFacts.spec_facts_strict?

      RspecPuppetFacts.warning "No facts were found in the FacterDB for Facter v#{facterversion} on #{filter_spec}, using v#{version} instead"
    end

    received_facts << facts
  end

  unless received_facts.any?
    RspecPuppetFacts.warning "No facts were found in the FacterDB for: #{filter.inspect}"
    return {}
  end

  os_facts_hash = {}
  received_facts.map do |facts|
    os_fact = RSpec.configuration.facterdb_string_keys ? facts['os'] : facts[:os]
    unless os_fact
      RspecPuppetFacts.warning "No os fact was found in FacterDB for: #{facts}"
      next
    end

    os = "#{os_fact['name'].downcase}-#{os_fact['release']['major']}-#{os_fact['hardware']}"
    next if RspecPuppetFacts.spec_facts_os_filter && !os.start_with?(RspecPuppetFacts.spec_facts_os_filter)

    facts.merge! RspecPuppetFacts.common_facts
    os_facts_hash[os] = RspecPuppetFacts.with_custom_facts(os, facts)
  end

  os_facts_hash
end
stringify_keys(hash) click to toggle source

@api private

# File lib/rspec-puppet-facts.rb, line 163
def stringify_keys(hash)
  hash.to_h { |k, v| [k.to_s, v.is_a?(Hash) ? stringify_keys(v) : v] }
end