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.


