#!/usr/bin/env ruby
# frozen_string_literal: true

# This is the external nodes script to allow Salt to retrieve info about a host
# from Foreman.  It also uploads a node's grains to Foreman, if the setting is
# enabled.

require 'yaml'

settings_file = '/etc/salt/foreman.yaml'
SETTINGS = YAML.load_file(settings_file)

require 'net/http'
require 'net/https'
require 'etc'
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
    begin
      require 'rubygems'
    rescue Exception
      nil
    end
    require 'json'
  rescue LoadError
    puts 'You need the `json` gem to use the Foreman ENC script'
    # code 1 is already used below
    exit 2
  end
end

def foreman_url
  "#{SETTINGS[:proto]}://#{SETTINGS[:host]}:#{SETTINGS[:port]}"
end

def valid_hostname?(hostname)
  hostname =~ /\A(([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])\z/
end

def get_grains(minion)
  grains = {
    :name  => minion,
    :facts => plain_grains(minion).merge(:_timestamp => Time.now, :_type => 'foreman_salt')
  }

  grains[:facts][:operatingsystem] = grains[:facts]['os']
  grains[:facts][:operatingsystemrelease] = grains[:facts]['osrelease']

  JSON.pretty_generate(grains)
rescue Exception => e
  puts "Could not get grains: #{e}"
  exit 1
end

def plain_grains(minion)
  # We have to get the grains from the cache, because the client
  # is probably running 'state.highstate' right now.

  result = IO.popen(['salt-run', '-l', 'quiet', '--output=json', 'cache.grains', minion]) do |io|
    io.read
  end

  grains = JSON.parse(result)

  raise 'No grains received from Salt master' unless grains

  plainify(grains[minion]).flatten.inject(&:merge)
end

def plainify(hash, prefix = nil)
  result = []
  hash.each_pair do |key, value|
    if value.is_a?(Hash)
      result.push plainify(value, get_key(key, prefix))
    elsif value.is_a?(Array)
      result.push plainify(array_to_hash(value), get_key(key, prefix))
    else
      new = {}
      new[get_key(key, prefix)] = value
      result.push new
    end
  end
  result
end

def array_to_hash(array)
  new = {}
  array.each_with_index { |v, index| new[index.to_s] = v }
  new
end

def get_key(key, prefix)
  [prefix, key].compact.join('::')
end

def upload_grains(minion)
  grains = get_grains(minion)
  uri = URI.parse("#{foreman_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         = grains
  res              = Net::HTTP.new(uri.host, uri.port)
  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
  elsif SETTINGS[:username] && SETTINGS[:password]
    req.basic_auth(SETTINGS[:username], SETTINGS[:password])
  end
  res.start { |http| http.request(req) }
rescue Exception => e
  raise "Could not send facts to Foreman: #{e}"
end

def enc(minion)
  url              = "#{foreman_url}/salt/node/#{minion}?format=yml"
  uri              = URI.parse(url)
  req              = Net::HTTP::Get.new(uri.request_uri)
  http             = Net::HTTP.new(uri.host, uri.port)
  http.use_ssl     = uri.scheme == 'https'
  if http.use_ssl?
    if SETTINGS[:ssl_ca] && !SETTINGS[:ssl_ca].empty?
      http.ca_file = SETTINGS[:ssl_ca]
      http.verify_mode = OpenSSL::SSL::VERIFY_PEER
    else
      http.verify_mode = OpenSSL::SSL::VERIFY_NONE
    end
    if SETTINGS[:ssl_cert] && !SETTINGS[:ssl_cert].empty? && SETTINGS[:ssl_key] && !SETTINGS[:ssl_key].empty?
      http.cert = OpenSSL::X509::Certificate.new(File.read(SETTINGS[:ssl_cert]))
      http.key  = OpenSSL::PKey::RSA.new(File.read(SETTINGS[:ssl_key]), nil)
    end
  elsif SETTINGS[:username] && SETTINGS[:password]
    req.basic_auth(SETTINGS[:username], SETTINGS[:password])
  end

  res = http.start { |conn| conn.request(req) }

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

minion = ARGV[0] || raise('Must provide minion as an argument')

raise 'Invalid hostname' unless valid_hostname? minion
begin
  result = ''

  if SETTINGS[:upload_grains]
    Timeout.timeout(SETTINGS[:timeout]) do
      upload_grains(minion)
    end
  end

  Timeout.timeout(SETTINGS[:timeout]) do
    result = enc(minion)
  end
  puts result
rescue Exception => e
  puts "Couldn't retrieve ENC data: #{e}"
  exit 1
end
