How to import facts in Foreman

Hello,

Currently I can’t see any facts from the clients managed by Foreman. The page is blank:

I followed these instructions to configure the collection of facts in Foreman as described here: Foreman :: Manual
And nevertheless I still have the issue that no facts are imported in Foreman.

It seems to be configured correctly according to these log entries:

root@testserver:/etc/puppetlabs/puppet# tail -f /var/log/foreman/production.log
2026-04-17T14:14:36 [I|app|2916b569] Processing by DashboardController#index as HTML
2026-04-17T14:14:36 [I|app|2916b569] Redirected to https://mgmt.company.com/users/login
2026-04-17T14:14:36 [I|app|2916b569] Filter chain halted as :require_login rendered or redirected
2026-04-17T14:14:36 [I|app|2916b569] Completed 302 Found in 3ms (ActiveRecord: 1.4ms | Allocations: 826)
2026-04-17T14:14:36 [I|app|aaffb19c] Started GET "/users/login" for 192.168.201.196 at 2026-04-17 14:14:36 +0200
2026-04-17T14:14:36 [I|app|aaffb19c] Processing by UsersController#login as HTML
2026-04-17T14:14:36 [I|app|aaffb19c]   Rendered users/login.html.erb within layouts/login (Duration: 0.5ms | Allocations: 271)
2026-04-17T14:14:36 [I|app|aaffb19c]   Rendered layouts/base.html.erb (Duration: 1.7ms | Allocations: 1706)
2026-04-17T14:14:36 [I|app|aaffb19c]   Rendered layout layouts/login.html.erb (Duration: 2.4ms | Allocations: 2168)
2026-04-17T14:14:36 [I|app|aaffb19c] Completed 200 OK in 4ms (Views: 2.7ms | ActiveRecord: 0.1ms | Allocations: 2968)
2026-04-17T14:14:58 [I|app|3772eb8d] Started POST "/api/hosts/facts" for 192.168.25.182 at 2026-04-17 14:14:58 +0200
2026-04-17T14:14:58 [I|app|3772eb8d] Processing by Api::V2::HostsController#facts as JSON
2026-04-17T14:14:58 [I|app|3772eb8d]   Parameters: {"facts"=>"[FILTERED]", "name"=>"node1.company.com", "certname"=>"node1.company.com", "apiv"=>"v2", "host"=>{"certname"=>"node1.company.com", "name"=>"node1.company.com"}}
2026-04-17T14:14:58 [I|app|3772eb8d] <Array> ["testserver.company.com"]
2026-04-17T14:14:58 [I|app|3772eb8d] Import facts for 'node1.company.com' completed. Added: 0, Updated: 0, Deleted 0 facts
2026-04-17T14:14:58 [I|app|3772eb8d] Completed 201 Created in 38ms (Views: 3.1ms | ActiveRecord: 6.8ms | Allocations: 19522)
2026-04-17T14:14:58 [I|app|e07efdcd] Started GET "/node/node1.company.com?format=yml" for 192.168.25.182 at 2026-04-17 14:14:58 +0200
2026-04-17T14:14:58 [I|app|e07efdcd] Processing by HostsController#externalNodes as YML
2026-04-17T14:14:58 [I|app|e07efdcd]   Parameters: {"name"=>"node1.company.com"}
2026-04-17T14:14:58 [I|app|e07efdcd] <Array> ["testserver.company.com"]
2026-04-17T14:14:58 [I|app|e07efdcd]   Rendered text template (Duration: 0.0ms | Allocations: 1)
2026-04-17T14:14:58 [I|app|e07efdcd] Completed 200 OK in 46ms (Views: 0.4ms | ActiveRecord: 6.6ms | Allocations: 31489)
2026-04-17T14:14:58 [I|app|c53d5a37] Started POST "/api/config_reports" for 192.168.25.182 at 2026-04-17 14:14:58 +0200
2026-04-17T14:14:58 [I|app|c53d5a37] Processing by Api::V2::ConfigReportsController#create as JSON
2026-04-17T14:14:58 [I|app|c53d5a37]   Parameters: {"config_report"=>"[FILTERED]", "apiv"=>"v2"}
2026-04-17T14:14:58 [I|app|c53d5a37] <Array> ["testserver.company.com"]
2026-04-17T14:14:58 [I|app|c53d5a37] Scanning report with: ForemanAnsible::AnsibleReportScanner, ReportScanner::PuppetReportScanner
2026-04-17T14:14:58 [I|app|c53d5a37] Imported report for node1.company.com in 27.8 ms, status refreshed in 19.5 ms
2026-04-17T14:14:58 [I|app|c53d5a37]   Rendered api/v2/config_reports/create.json.rabl (Duration: 1.0ms | Allocations: 737)
2026-04-17T14:14:58 [I|app|c53d5a37] Completed 201 Created in 57ms (Views: 1.6ms | ActiveRecord: 18.3ms | Allocations: 29214)

The Puppet configuration of the Foreman server looks like this:

[main]
    basemodulepath = /etc/puppetlabs/code/environments/common:/etc/puppetlabs/code/modules:/opt/puppetlabs/puppet/modules:/usr/share/puppet/modules
    certname = testserver.company.com
    codedir = /etc/puppetlabs/code
    environmentpath = /etc/puppetlabs/code/environments
    hiera_config = $confdir/hiera.yaml
    hostprivkey = $privatekeydir/$certname.pem { mode = 640 }
    logdir = /var/log/puppetlabs/puppet
    pluginfactsource = puppet:///pluginfacts
    pluginsource = puppet:///plugins
    privatekeydir = $ssldir/private_keys { group = service }
    reports = foreman
    rundir = /var/run/puppetlabs
    server = testserver.company.com
    show_diff = false
    ssldir = /etc/puppetlabs/puppet/ssl
    vardir = /opt/puppetlabs/puppet/cache

[agent]
    classfile = $statedir/classes.txt
    default_schedules = false
    environment = production
    masterport = 8140
    noop = false
    report = true
    runinterval = 1800
    splay = false
    splaylimit = 1800
    usecacheonfailure = true

[server]
    autosign = /etc/puppetlabs/puppet/autosign.conf { mode = 0664 }
    ca = true
    certname = testserver.company.com
    external_nodes = /etc/puppetlabs/puppet/node.rb
    logdir = /var/log/puppetlabs/puppetserver
    node_terminus = exec
    parser = current
    rundir = /var/run/puppetlabs/puppetserver
    ssldir = /etc/puppetlabs/puppet/ssl
    storeconfigs = false
    strict_variables = false
    vardir = /opt/puppetlabs/server/data/puppetserver

The Puppet configuration of a client looks like this:

[main]
certname = node1.company.com
server = testserver.company.com
ca = false

report = true
reports = foreman
reporturl = https://testserver.company.com:8443/reports

hostcert = /etc/puppetlabs/puppet/ssl/certs/node1.company.com.pem
hostprivkey = /etc/puppetlabs/puppet/ssl/private_keys/node1.company.com.pem
localcacert = /etc/puppetlabs/puppet/ssl/certs/ca.pem
crl = /etc/puppetlabs/puppet/ssl/crl
certificate_revocation = false

environment = production
logdir = /var/log/puppetlabs/puppet
rundir = /var/run/puppetlabs
vardir = /opt/puppetlabs/puppet/cache
rich_data = false
allow_pson_serialization = true
exclude_unchanged_resources = false
number_of_facts_soft_limit = 2048
preprocess_deferred = true
strict = warning
strict_variables = false

[agent]
server = testserver.company.com
environment = production
report = true
reports = foreman
reporturl = https://testserver.company.com:8443/reports
runinterval = 30m
use_cached_facts = false
ignorecache = true
include_legacy_facts = true
pluginsync = true
rich_data = true

The host facts are available as yaml files:

I could successfully retrieve the facts of a client on the Foreman server using the command “/etc/puppetlabs/puppet/node.rb node1.company.com”, as shown in the figure below:

It seems that the node.rb file is not the issue. It looks like this:

#!/usr/bin/env ruby

# Script usually acts as an ENC for a single host, with the certname supplied as argument
#   if 'facts' is true, the YAML facts for the host are uploaded
#   ENC output is printed and cached
#
# If --push-facts is given as the only arg, it uploads facts for all hosts and then exits.
# Useful in scenarios where the ENC isn't used.

require 'rbconfig'
require 'yaml'

if RbConfig::CONFIG['host_os'] =~ /freebsd|dragonfly/i
  $settings_file ||= '/usr/local/etc/puppet/foreman.yaml'
else
  $settings_file ||= File.exist?('/etc/puppetlabs/puppet/foreman.yaml') ? '/etc/puppetlabs/puppet/foreman.yaml' : '/etc/puppet/foreman.yaml'
end

SETTINGS = YAML.load_file($settings_file)

# Default external encoding
if defined?(Encoding)
  Encoding.default_external = Encoding::UTF_8
end

def url
  SETTINGS[:url] || raise("Must provide URL in #{$settings_file}")
end

def puppetdir
  SETTINGS[:puppetdir] || raise("Must provide puppet base directory in #{$settings_file}")
end

def puppetuser
  SETTINGS[:puppetuser] || 'puppet'
end

def fact_extension
  SETTINGS[:fact_extension] || 'yaml'
end

def fact_directory
  data_dir = fact_extension == 'yaml' ? 'yaml' : 'server_data'
  File.join(puppetdir, data_dir, 'facts')
end

def fact_file(certname)
  File.join(fact_directory, "#{certname}.#{fact_extension}")
end

def fact_files
  Dir[File.join(fact_directory, "*.#{fact_extension}")]
end

def certname_from_filename(filename)
  File.basename(filename, ".#{fact_extension}")
end

def stat_file(certname)
  FileUtils.mkdir_p "#{puppetdir}/yaml/foreman/"
  "#{puppetdir}/yaml/foreman/#{certname}.yaml"
end

def tsecs
  SETTINGS[:timeout] || 10
end

def thread_count
  return SETTINGS[:threads].to_i if not SETTINGS[:threads].nil? and SETTINGS[:threads].to_i > 0
  require 'facter'
  processors = Facter.value(:processorcount).to_i
  processors > 0 ? processors : 1
end

class Http_Fact_Requests
  include Enumerable

  def initialize
    @results_array = []
  end

  def <<(val)
    @results_array << val
  end

  def each(&block)
    @results_array.each(&block)
  end

  def pop
    @results_array.pop
  end
end

class FactUploadError < StandardError; end
class NodeRetrievalError < StandardError; end

require 'etc'
require 'net/http'
require 'net/https'
require 'fileutils'
require 'timeout'
begin
  require 'json'
rescue LoadError
  # Debian packaging guidelines state to avoid needing rubygems, so
  # we only try to load it if the first require fails (for RPMs)
  begin
    require 'rubygems' rescue nil
    require 'json'
  rescue LoadError => e
    puts "You need the `json` gem to use the Foreman ENC script"
    # code 1 is already used below
    exit 2
  end
end

def parse_file(filename)
  case File.extname(filename)
  when '.yaml'
    data = File.read(filename)
    YAML.safe_load(data.gsub(/\!ruby\/object.*$/,''), permitted_classes: [Symbol, Time])
  when '.json'
    JSON.parse(File.read(filename))
  else
    raise "Unknown extension for file '#{filename}'"
  end
end

def empty_values_hash?(facts_file)
  puppet_facts = parse_file(facts_file)
  puppet_facts['values'].empty?
end

def process_host_facts(certname)
  f = fact_file(certname)
  if File.size(f) != 0
    if empty_values_hash?(f)
      puts "Empty values hash in fact file #{f}, not uploading"
      return 0
    end

    req = generate_fact_request(certname, f)
    begin
      upload_facts(certname, req) if req
      return 0
    rescue => e
      $stderr.puts "During fact upload occurred an exception: #{e}"
      return 1
    end
  else
    $stderr.puts "Fact file #{f} does not contain any facts"
    return 2
  end
end

def process_all_facts(http_requests)
  fact_files.each do |f|
    # Skip empty host fact files
    if File.size(f) != 0
      if empty_values_hash?(f)
        puts "Empty values hash in fact file #{f}, not uploading"
        next
      end

      certname = certname_from_filename(f)
      req = generate_fact_request(certname, f)
      if http_requests
        http_requests << [certname, req]
      elsif req
        upload_facts(certname, req)
      end
    else
      $stderr.puts "Fact file #{f} does not contain any fact"
    end
  end
end

def build_body(certname,filename)
  puppet_facts = parse_file(filename)
  hostname     = puppet_facts['values']['fqdn'] || certname

  # if there is no environment in facts
  # get it from node file ({puppetdir}/yaml/node/
  unless puppet_facts['values'].key?('environment') || puppet_facts['values'].key?('agent_specified_environment')
    node_filename = filename.sub('/facts/', '/node/')
    if File.exist?(node_filename)
      node_data = parse_file(node_filename)

      if node_data.key?('environment')
        puppet_facts['values']['environment'] = node_data['environment']
      end
    end
  end

  begin
    require 'facter'
    puppet_facts['values']['puppetmaster_fqdn'] = Facter.value('networking.fqdn').to_s
  rescue LoadError
    puppet_facts['values']['puppetmaster_fqdn'] = `hostname -f`.strip
  end

  # filter any non-printable char from the value, if it is a String
  puppet_facts['values'].each do |key, val|
    if val.is_a? String
      puppet_facts['values'][key] = val.scan(/[[:print:]]/).join
    end
  end

  {'facts' => puppet_facts['values'], 'name' => hostname, 'certname' => certname}
end

def initialize_http(uri)
  res              = Net::HTTP.new(uri.host, uri.port)
  res.open_timeout = SETTINGS[:timeout]
  res.read_timeout = SETTINGS[:timeout]
  res.use_ssl      = uri.scheme == 'https'
  if res.use_ssl?
    if SETTINGS[:ssl_ca] && !SETTINGS[:ssl_ca].empty?
      res.ca_file = SETTINGS[:ssl_ca]
      res.verify_mode = OpenSSL::SSL::VERIFY_PEER
    else
      res.verify_mode = OpenSSL::SSL::VERIFY_NONE
    end
    if SETTINGS[:ssl_cert] && !SETTINGS[:ssl_cert].empty? && SETTINGS[:ssl_key] && !SETTINGS[:ssl_key].empty?
      res.cert = OpenSSL::X509::Certificate.new(File.read(SETTINGS[:ssl_cert]))
      res.key  = OpenSSL::PKey::RSA.new(File.read(SETTINGS[:ssl_key]), nil)
    end
  end
  res
end

def generate_fact_request(certname, filename)
  # Temp file keeping the last run time
  stat = stat_file("#{certname}-push-facts")
  last_run = File.exist?(stat) ? File.stat(stat).mtime.utc : Time.now - 365*24*60*60
  last_fact = File.exist?(filename) ? File.stat(filename).mtime.utc : Time.at(0)
  if last_fact > last_run
    begin
      uri = URI.parse("#{url}/api/hosts/facts")
      req = Net::HTTP::Post.new(uri.request_uri)
      req.add_field('Accept', 'application/json,version=2' )
      req.content_type = 'application/json'
      req.body         = build_body(certname, filename).to_json
      req
    rescue => e
      raise "Could not generate facts for Foreman: #{e}"
    end
  end
end

def cache(certname, result)
  File.open(stat_file(certname), 'w') {|f| f.write(result) }
end

def read_cache(certname)
  File.read(stat_file(certname))
rescue => e
  raise "Unable to read from Cache file: #{e}"
end

def enc(certname)
  uri = URI.parse("#{url}/node/#{certname}?format=yml")
  req = Net::HTTP::Get.new(uri.request_uri)
  initialize_http(uri).start do |http|
    response = http.request(req)

    unless response.code == "200"
      raise NodeRetrievalError, "Error retrieving node #{certname}: #{response.class}\nCheck Foreman's /var/log/foreman/production.log for more information."
    end
    response.body
  end
end

def upload_facts(certname, req)
  return nil if req.nil?
  uri = URI.parse("#{url}/api/hosts/facts")
  begin
    initialize_http(uri).start do |http|
      response = http.request(req)
      if response.code.start_with?('2')
        cache("#{certname}-push-facts", "Facts from this host were last pushed to #{uri} at #{Time.now}\n")
      else
        $stderr.puts "#{certname}: During the fact upload the server responded with: #{response.code} #{response.message}. Error is ignored and the execution continues."
        $stderr.puts response.body
      end
    end
  rescue => e
    $stderr.puts "During fact upload occured an exception: #{e}"
    raise FactUploadError, "Could not send facts to Foreman: #{e}"
  end
end

def upload_facts_parallel(http_fact_requests, wait = true)
  t = thread_count.times.map {
    Thread.new(http_fact_requests) do |fact_requests|
    while factref = fact_requests.pop
      certname         = factref[0]
      httpobj          = factref[1]
      if httpobj
        upload_facts(certname, httpobj)
      end
    end
    end
  }
  if wait
    t.each(&:join)
  end
end

def watch_and_send_facts(parallel)
  begin
    require 'inotify'
  rescue LoadError
    puts "You need the `ruby-inotify` (not inotify!) gem to watch for fact updates"
    exit 2
  end

  watch_descriptors = []
  pending = []
  threads = thread_count
  last_send = Time.now

  inotify_limit = `sysctl fs.inotify.max_user_watches`.gsub(/[^\d]/, '').to_i

  inotify = Inotify.new

  fact_dir = fact_directory

  # actually we need only MOVED_TO events because puppet uses File.rename after tmp file created and flushed.
  # see lib/puppet/util.rb near line 469
  inotify.add_watch(fact_dir, Inotify::CREATE | Inotify::MOVED_TO )

  files = fact_files

  if files.length > inotify_limit
    puts "Looks like your inotify watch limit is #{inotify_limit} but you are asking to watch at least #{files.length} fact files."
    puts "Increase the watch limit via the system tunable fs.inotify.max_user_watches, exiting."
    exit 2
  end

  files.each do |f|
    begin
      watch_descriptors[inotify.add_watch(f, Inotify::CLOSE_WRITE)] = f
    end
  end

  inotify.each_event do |ev|
    fn = watch_descriptors[ev.wd]
    add_watch = false

    unless fn
      # inotify returns basename for renamed file as ev.name
      # but we need full path
      fn = File.join(fact_dir, ev.name)
      add_watch = true
    end

    if File.extname(fn) != ".#{fact_extension}"
      next
    end

    if add_watch || (ev.mask & Inotify::ONESHOT)
      watch_descriptors[inotify.add_watch(fn, Inotify::CLOSE_WRITE)] = fn
    end

    if fn
      certname = certname_from_filename(fn)
      req = generate_fact_request certname, fn
      if parallel
        pending << [certname,req]
      else
        upload_facts(certname,req)
      end
    end
    if parallel && (pending.length >= threads || ((last_send + 5) < Time.now))
      if pending.length > 0
        upload_facts_parallel(pending, false)
        pending = []
      end
      last_send = Time.now
    end
  end
end

# Actual code starts here

if __FILE__ == $0 then
  # Setuid to puppet user if we can
  begin
    Process::GID.change_privilege(Etc.getgrnam(puppetuser).gid) unless Etc.getpwuid.name == puppetuser
    Process::UID.change_privilege(Etc.getpwnam(puppetuser).uid) unless Etc.getpwuid.name == puppetuser
    # Facter (in thread_count) tries to read from $HOME, which is still /root after the UID change
    ENV['HOME'] = Etc.getpwnam(puppetuser).dir
    # Change CWD to the determined home directory before continuing to make
    # sure we don't reside in /root or anywhere else we don't have access
    # permissions
    Dir.chdir ENV['HOME']
  rescue
    $stderr.puts "cannot switch to user #{puppetuser}, continuing as '#{Etc.getpwuid.name}'"
  end

  begin
    no_env = ARGV.delete("--no-environment")
    watch = ARGV.delete("--watch-facts")
    push_facts_parallel = ARGV.delete("--push-facts-parallel")
    push_facts = ARGV.delete("--push-facts")
    if watch && ! ( push_facts || push_facts_parallel )
        raise "Cannot watch for facts without specifying --push-facts or --push-facts-parallel"
    end
    if push_facts
      # push all facts files to Foreman and don't act as an ENC
      if ARGV.empty?
        process_all_facts(false)
      else
        process_host_facts(ARGV[0])
      end
    elsif push_facts_parallel
      http_fact_requests = Http_Fact_Requests.new
      process_all_facts(http_fact_requests)
      upload_facts_parallel(http_fact_requests)
    else
      certname = ARGV[0] || raise("Must provide certname as an argument")
      #
      # query External node
      begin
        result = ""
        Timeout.timeout(tsecs) do
          # send facts to Foreman - enable 'facts' setting to activate
          # if you use this option below, make sure that you don't send facts to foreman via the rake task or push facts alternatives.
          #
          if SETTINGS[:facts]
            req = generate_fact_request(certname, fact_file(certname))
            upload_facts(certname, req)
          end

          result = enc(certname)
          cache(certname, result)
        end
      rescue Timeout::Error, SocketError, Errno::EHOSTUNREACH, Errno::ECONNREFUSED, NodeRetrievalError, FactUploadError => e
        $stderr.puts "Serving cached ENC: #{e}"
        # Read from cache, we got some sort of an error.
        result = read_cache(certname)
      end

      if no_env
        require 'yaml'
        yaml = YAML.safe_load(result)
        yaml.delete('environment')
        # Always reset the result to back to clean yaml on our end
        puts yaml.to_yaml
      else
        puts result
      end
    end
  rescue => e
    warn e
    exit 1
  end
  if watch
    watch_and_send_facts(push_facts_parallel)
  end
end

Does anyone know what the issue could be? Any help or hints are appreciated. Thank you in advance for your help.

This line looks weird, like the upload didn’t contain anything or the syntax was different. The facts upload mechanism seems to work though.