theforeman/foreman

View on GitHub
app/services/fact_importer.rb

Summary

Maintainability
A
2 hrs
Test Coverage
class FactImporter
  include Foreman::TelemetryHelper

  delegate :logger, :to => :Rails
  attr_reader :counters

  def self.support_background
    false
  end

  def self.authorized_smart_proxy_features
    # When writing your own Fact importer, provide feature(s) of authorized Smart Proxies
    Rails.logger.debug("Importer #{self} does not implement authorized_smart_proxy_features.")
    []
  end

  def self.excluded_facts_regex
    Setting.convert_array_to_regexp(
      Setting[:excluded_facts],
      {
        :prefix => '(\A|.*::|\A(facter_)?(mtu|macaddress|(ipaddress|network|netmask)6?)_)',
        :suffix => '(\Z|::.*)',
      }
    )
  end

  def initialize(host, facts = {})
    @error    = false
    @host     = host
    @facts    = normalize(facts)
    @counters = {}
  end

  # expect a facts hash
  def import!
    # This function uses its own transactions that should not be included
    # in the transaction that handles fact values
    ensure_fact_names

    ActiveRecord::Base.transaction do
      delete_removed_facts
      update_facts
      add_new_facts
    end

    report_error(@error) if @error
    logger.info("Import facts for '#{host}' completed. Added: #{counters[:added]}, Updated: #{counters[:updated]}, Deleted #{counters[:deleted]} facts")
    telemetry_increment_counter(:importer_facts_count_processed, counters[:added], type: fact_name_class_humanized, action: :added)
    telemetry_increment_counter(:importer_facts_count_processed, counters[:updated], type: fact_name_class_humanized, action: :updated)
    telemetry_increment_counter(:importer_facts_count_processed, counters[:deleted], type: fact_name_class_humanized, action: :deleted)
  end

  def report_error(error)
    Foreman::Logging.exception("Error during fact import for #{@host.name}", error)
    raise ::Foreman::WrappedException.new(error, N_("Import of facts failed for host %s"), @host.name)
  end

  # to be defined in children
  def fact_name_class
    raise NotImplementedError
  end

  def fact_name_class_humanized
    @class_humanized ||= fact_name_class.name.demodulize.underscore
  end

  private

  attr_reader :host, :facts, :fact_names, :facts_to_create

  def fact_name_class_name
    @fact_name_class_name ||= fact_name_class.name
  end

  def ensure_fact_names
    initialize_fact_names

    missing_keys = facts.keys - fact_names.keys
    add_missing_fact_names(missing_keys)
  end

  def add_missing_fact_names(missing_keys)
    missing_keys.sort.each do |fact_name|
      # create a new record and make sure it could be saved.
      name_record = fact_name_class.new(fact_name_attributes(fact_name))

      ensure_no_active_transaction

      ActiveRecord::Base.transaction(:requires_new => true) do
        save_name_record(name_record)
      rescue ActiveRecord::RecordNotUnique
        name_record = nil
      end

      # if the record could not be saved in the previous transaction,
      # re-get the record outside of transaction
      if name_record.nil?
        name_record = fact_name_class.find_by!(name: fact_name)
      end

      # make the new record available immediately for other fact_name_attributes calls
      @fact_names[fact_name] = name_record
    end
  end

  def initialize_fact_names
    name_records = fact_name_class.unscoped.where(:name => facts.keys, :type => fact_name_class_name).reorder('')
    @fact_names = name_records.index_by(&:name)
  end

  def fact_name_attributes(fact_name)
    {
      name: fact_name,
    }
  end

  def delete_removed_facts
    delete_query = FactValue.joins(:fact_name).where(:host => host, 'fact_names.type' => fact_name_class_name).where.not(:fact_name => fact_names.values).reorder('')
    @counters[:deleted] = delete_query.delete_all
  end

  def add_new_fact(name)
    # if the host does not exist yet, we don't have an host_id to use the fact_values table.
    method = host.new_record? ? :build : :create!
    fact_name = fact_names[name]
    host.fact_values.send(method, :value => facts[name], :fact_name => fact_name)
  rescue => e
    logger.error("Fact #{name} could not be imported because of #{e.message}")
    @error = e
  end

  def add_new_facts
    facts_to_create.each { |f| add_new_fact(f) }
    @counters[:added] = facts_to_create.size
  end

  def update_facts
    time = Time.now.utc
    updated = 0
    db_facts = host.fact_values.joins(:fact_name).where(fact_names: {type: fact_name_class_name}).reorder(nil).pluck(:name, :value, :id)
    db_facts.each do |name, value, id|
      next unless fact_names.include?(name)
      new_value = facts[name]
      if value != new_value
        # skip callbacks/validations
        FactValue.where(id: id).update_all(:value => new_value, :updated_at => time)
        updated += 1
      end
    end
    db_facts_names = db_facts.map(&:first) & fact_names.keys
    @facts_to_create = facts.keys - db_facts_names
    @counters[:updated] = updated
  end

  def normalize(facts)
    normalized_facts = {}
    facts.each do |k, v|
      key = k.to_s
      val = v.to_s

      normalized_facts[key] = val unless val.empty? || key.match(excluded_facts)
    end
    normalized_facts
  end

  def ensure_no_active_transaction
    message = 'Fact names should be added outside of global transaction.'
    if Rails.env.test?
      message += <<-TEST_ERROR
        You are updating facts from a test, you can use allow_transactions_for or
        allow_transactions_for_any_importer from fact_importer_test_helper.rb if you
        wish to continue with facts upload. Please be aware that fact uploading in tests
        could not run in parallel.
      TEST_ERROR
    end
    raise message if ActiveRecord::Base.connection.transaction_open?
  end

  def save_name_record(name_record)
    name_record.save!(:validate => false)
  end

  def excluded_facts
    @excluded_facts ||= FactImporter.excluded_facts_regex
  end
end