Permalink
Cannot retrieve contributors at this time
#!/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 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 | |
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 empty_values_hash?(facts_file) | |
facts = File.read(facts_file) | |
puppet_facts = YAML::load(facts.gsub(/\!ruby\/object.*$/,'')) | |
puppet_facts['values'].empty? | |
end | |
def process_host_facts(certname) | |
f = "#{puppetdir}/yaml/facts/#{certname}.yaml" | |
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 occured an exception: #{e}" | |
return 1 | |
end | |
else | |
$stderr.puts "Fact file #{f} does not contain any fact" | |
return 2 | |
end | |
end | |
def process_all_facts(http_requests) | |
Dir["#{puppetdir}/yaml/facts/*.yaml"].each do |f| | |
certname = File.basename(f, ".yaml") | |
# Skip empty host fact yaml files | |
if File.size(f) != 0 | |
if empty_values_hash?(f) | |
puts "Empty values hash in fact file #{f}, not uploading" | |
next | |
end | |
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 quote_macs! facts | |
# Adds single quotes to all unquoted mac addresses in the raw yaml fact string | |
# if they might otherwise be interpreted as base60 ints | |
facts.gsub!(/: ([0-5][0-9](:[0-5][0-9]){5})$/,": '\\1'") | |
end | |
def build_body(certname,filename) | |
# Strip the Puppet:: ruby objects and keep the plain hash | |
facts = File.read(filename) | |
quote_macs! facts if YAML.load('22:22:22:22:22:22').is_a? Integer | |
puppet_facts = YAML::load(facts.gsub(/\!ruby\/object.*$/,'')) | |
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_yaml = File.read(node_filename) | |
node_data = YAML::load(node_yaml.gsub(/\!ruby\/object.*$/,'')) | |
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(: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.exists?(stat) ? File.stat(stat).mtime.utc : Time.now - 365*24*60*60 | |
last_fact = File.exists?(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 "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 | |
# 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("#{puppetdir}/yaml/facts", Inotify::CREATE | Inotify::MOVED_TO ) | |
yamls = Dir["#{puppetdir}/yaml/facts/*.yaml"] | |
if yamls.length > inotify_limit | |
puts "Looks like your inotify watch limit is #{inotify_limit} but you are asking to watch at least #{yamls.length} fact files." | |
puts "Increase the watch limit via the system tunable fs.inotify.max_user_watches, exiting." | |
exit 2 | |
end | |
yamls.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 | |
if !fn | |
# inotify returns basename for renamed file as ev.name | |
# but we need full path | |
fn = "#{puppetdir}/yaml/facts/#{ev.name}" | |
add_watch = true | |
end | |
if File.extname(fn) != ".yaml" | |
next | |
end | |
if add_watch || (ev.mask & Inotify::ONESHOT) | |
watch_descriptors[inotify.add_watch(fn, Inotify::CLOSE_WRITE)] = fn | |
end | |
if fn | |
certname = File.basename(fn, ".yaml") | |
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, "#{puppetdir}/yaml/facts/#{certname}.yaml" | |
upload_facts(certname, req) | |
end | |
result = enc(certname) | |
cache(certname, result) | |
end | |
rescue TimeoutError, SocketError, Errno::EHOSTUNREACH, Errno::ECONNREFUSED, 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.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 |