#!/usr/bin/ruby
# 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
    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 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)
  begin
    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 => e
    puts "Could not get grains: #{e}"
    exit 1
  end
end

def plain_grains(minion)
  # We have to get the grains from the cache, because the client
  # is probably running 'state.highstate' right now.
  #
  # salt-run doesn't support directly outputting to json,
  # so we have resort to python to extract the grains.
  # Based on https://github.com/saltstack/salt/issues/9444

script = <<-EOF
#!/usr/bin/env python
import json
import os
import sys

import salt.config
import salt.runner

if __name__ == '__main__':
    __opts__ = salt.config.master_config(
            os.environ.get('SALT_MASTER_CONFIG', '/etc/salt/master'))
    runner = salt.runner.Runner(__opts__)  
   
    stdout_bak = sys.stdout
    with open(os.devnull, 'wb') as f:
        sys.stdout = f  
        ret = runner.cmd('cache.grains', ['#{minion}'])
    sys.stdout = stdout_bak

    print json.dumps(ret)
EOF

  result = IO.popen('python 2>/dev/null', mode='r+') do |python|
    python.write script
    python.close_write
    result = python.read
  end

  grains = JSON.load(result)

  if grains
    plainify(grains[minion]).flatten.inject(&:merge)
  else
    raise 'No grains received from Salt master'
  end
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)
  begin
    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 => e
    raise "Could not send facts to Foreman: #{e}"
  end
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 { |http| http.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 => e
  puts "Couldn't retrieve ENC data: #{e}"
  exit 1
end