<commit_before>#
# Foreman hook that detects when a host has been renamed and runs a 'rename' hook
#
# Copyright (c) 2014 Bronto Software Inc.
#
# Permission is hereby granted, free of charge, to any person obtaining a copy of
# this software and associated documentation files (the "Software"), to deal in
# the Software without restriction, including without limitation the rights to
# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
# the Software, and to permit persons to whom the Software is furnished to do so,
# subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
#

raise 'Unsupported version of Ruby' unless RUBY_VERSION >= '1.9.3'

module ForemanHook
  class HostRename
    require 'fileutils'
    require 'json'
    require 'logger'
    require 'kwalify'
    require 'sqlite3'
    require 'rest_client'
    require 'pp'
    require 'yaml'
    
    attr_accessor :database_path

    # Given a nested hash, convert all keys from String to Symbol type
    # Based on http://stackoverflow.com/questions/800122/best-way-to-convert-strings-to-symbols-in-hash
    #
    def symbolize(obj)
      return obj.inject({}){|memo,(k,v)| memo[k.to_sym] =  symbolize(v); memo} if obj.is_a? Hash
      return obj.inject([]){|memo,v    | memo           << symbolize(v); memo} if obj.is_a? Array
      return obj
    end
    
    # Parse the configuration file
    def parse_config(conffile = nil)
      conffile ||= Dir.glob([
	  "/etc/foreman_hooks-host_rename/settings.yaml",
	  "#{confdir}/settings.yaml"])[0]
      raise "Could not locate the configuration file" if conffile.nil?
    
      # Parse the configuration file
      config = {
          hook_user: 'foreman',
          database_path: '/var/tmp/foreman_hooks-host_rename.db',
          log_path: '/var/tmp/foreman_hooks-host_rename.log',
          log_level: 'warn',
          rename_hook_command: '/bin/true',
      }.merge(symbolize(YAML.load(File.read(conffile))))
      config.each do |k,v|
        instance_variable_set("@#{k}",v)
      end
    
      # Validate the schema
      document = Kwalify::Yaml.load_file(conffile)
      schema = Kwalify::Yaml.load_file("#{confdir}/schema.yaml")
      validator = Kwalify::Validator.new(schema)
      errors = validator.validate(document)
      if errors && !errors.empty?
        puts "WARNING: The following errors were found in #{conffile}:"
        for e in errors
          puts "[#{e.path}] #{e.message}"
        end
        raise "Errors in the configuration file"
      end
    
      check_script @rename_hook_command
    end
    
    # Do additional sanity checking on the database path
    def validate_database
      db = @database_path
      raise "bad mode of #{db}" unless File.world_readable?(db).nil?
    end

    # Do additional sanity checking on a hook script
    def check_script(path)
      binary=path.split(' ')[0]
      raise "#{path} does not exist" unless File.exist? binary
      raise "#{path} is not executable" unless File.executable? binary
      path
    end
    
    # Given an absolute [+path+] within the Foreman API, return the full URI
    def foreman_uri(path)
      raise ArgumentError, 'path must start with a /' unless path =~ /^\//
      ['https://', @foreman_user, ':', @foreman_password, '@',
       @foreman_host, '/api/v2', path].join('')
    end
    
    # Get all the host IDs and FQDNs and populate the host table
    def sync_host_table
      uri = foreman_uri('/hosts?per_page=9999999')
      debug "Loading hosts from #{uri}"
      json = RestClient.get uri
      debug "Got JSON: #{json}"
      JSON.parse(json)['results'].each do |rec|
        @db.execute "insert into host (id,name) values ( ?, ? )",
                   rec['id'], rec['name']
      end
    end

    # Initialize an empty database
    def initialize_database
      @db = SQLite3::Database.new @database_path
      File.chmod 0600, @database_path
      begin
        @db.execute 'drop table if exists host;'
        @db.execute <<-SQL
            create table host (
              id INT,
              name varchar(254)
            );
        SQL
        sync_host_table
      rescue
        File.unlink @database_path
        raise
      end
    end
    
    # Open a database connection. If the database does not exist, initialize it.
    def open_database
      if File.exist? @database_path
        validate_database
        @db = SQLite3::Database.new @database_path
      else
        initialize_database
      end
    end
    
    # Update the database based on the foreman_hook
    def execute_hook_action
      @rename = false
      name = @rec['host']['name']
      id = @rec['host']['id']
    
      case @action
      when 'create'
        sql = "insert into host (id, name) values (?, ?)"
        params = [id, name]
      when 'update'
        # Check if we are renaming the host
        @old_name = @db.get_first_row('select name from host where id = ?', id)[0]
        if @old_name.nil?
          warn 'received an update for a non-existent host'
        else
          @rename = @old_name != name
        end
        debug "checking for a rename: old=#{@old_name} new=#{name} rename?=#{@rename}"
    
        sql = 'update host set name = ? where id = ?'
        params = [name, id]
      when 'destroy'
        sql = 'delete from host where id = ?'
        params = [id]
      else
        raise ArgumentError, "unsupported action: #{ARGV[0]}"
      end
      debug "updating database; id=#{id} name=#{name} sql=#{sql}"
      stm = @db.prepare sql
      stm.bind_params *params
      stm.execute
    end
    
    # Check if the host has been renamed
    # @return true, if the host has been renamed
    def rename?
      @rename
    end
    
    def execute_rename_action
      raise 'old_name is nil' if @old_name.nil? 
      raise 'new_name is nil' if @rec['host']['name'].nil?
      cmd = @rename_hook_command + ' ' + @old_name + ' ' + @rec['host']['name']
      debug "Running the rename hook action: #{cmd}"
      open('|' + cmd, 'w+') do |subprocess|
        subprocess.write @rec.to_json
      end
      rc = $?.exitstatus
      warn "Hook exited with status #{rc}" if rc != 0
    end
    
    def parse_hook_data
      @action = ARGV[0]   # one of: create, update, destroy
      @rec = JSON.parse $stdin.read
      debug "action=#{@action} rec=#{@rec.inspect}"
    end
    
    def log_level=(level)
      @log_level = level
      @log.level = level
    end

    def open_logfile
      @log = Logger.new(@log_path, 10, 1024000)
      case @log_level
      when 'debug'
        @log.level = Logger::DEBUG
      when 'warn'<commit_msg>redirect output to the log<commit_after>#
# Foreman hook that detects when a host has been renamed and runs a 'rename' hook
#
# Copyright (c) 2014 Bronto Software Inc.
#
# Permission is hereby granted, free of charge, to any person obtaining a copy of
# this software and associated documentation files (the "Software"), to deal in
# the Software without restriction, including without limitation the rights to
# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
# the Software, and to permit persons to whom the Software is furnished to do so,
# subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
#

raise 'Unsupported version of Ruby' unless RUBY_VERSION >= '1.9.3'

module ForemanHook
  class HostRename
    require 'fileutils'
    require 'json'
    require 'logger'
    require 'kwalify'
    require 'sqlite3'
    require 'rest_client'
    require 'pp'
    require 'yaml'
    
    attr_accessor :database_path

    # Given a nested hash, convert all keys from String to Symbol type
    # Based on http://stackoverflow.com/questions/800122/best-way-to-convert-strings-to-symbols-in-hash
    #
    def symbolize(obj)
      return obj.inject({}){|memo,(k,v)| memo[k.to_sym] =  symbolize(v); memo} if obj.is_a? Hash
      return obj.inject([]){|memo,v    | memo           << symbolize(v); memo} if obj.is_a? Array
      return obj
    end
    
    # Parse the configuration file
    def parse_config(conffile = nil)
      conffile ||= Dir.glob([
	  "/etc/foreman_hooks-host_rename/settings.yaml",
	  "#{confdir}/settings.yaml"])[0]
      raise "Could not locate the configuration file" if conffile.nil?
    
      # Parse the configuration file
      config = {
          hook_user: 'foreman',
          database_path: '/var/tmp/foreman_hooks-host_rename.db',
          log_path: '/var/tmp/foreman_hooks-host_rename.log',
          log_level: 'warn',
          rename_hook_command: '/bin/true',
      }.merge(symbolize(YAML.load(File.read(conffile))))
      config.each do |k,v|
        instance_variable_set("@#{k}",v)
      end
    
      # Validate the schema
      document = Kwalify::Yaml.load_file(conffile)
      schema = Kwalify::Yaml.load_file("#{confdir}/schema.yaml")
      validator = Kwalify::Validator.new(schema)
      errors = validator.validate(document)
      if errors && !errors.empty?
        puts "WARNING: The following errors were found in #{conffile}:"
        for e in errors
          puts "[#{e.path}] #{e.message}"
        end
        raise "Errors in the configuration file"
      end
    
      check_script @rename_hook_command
    end
    
    # Do additional sanity checking on the database path
    def validate_database
      db = @database_path
      raise "bad mode of #{db}" unless File.world_readable?(db).nil?
    end

    # Do additional sanity checking on a hook script
    def check_script(path)
      binary=path.split(' ')[0]
      raise "#{path} does not exist" unless File.exist? binary
      raise "#{path} is not executable" unless File.executable? binary
      path
    end
    
    # Given an absolute [+path+] within the Foreman API, return the full URI
    def foreman_uri(path)
      raise ArgumentError, 'path must start with a /' unless path =~ /^\//
      ['https://', @foreman_user, ':', @foreman_password, '@',
       @foreman_host, '/api/v2', path].join('')
    end
    
    # Get all the host IDs and FQDNs and populate the host table
    def sync_host_table
      uri = foreman_uri('/hosts?per_page=9999999')
      debug "Loading hosts from #{uri}"
      json = RestClient.get uri
      debug "Got JSON: #{json}"
      JSON.parse(json)['results'].each do |rec|
        @db.execute "insert into host (id,name) values ( ?, ? )",
                   rec['id'], rec['name']
      end
    end

    # Initialize an empty database
    def initialize_database
      @db = SQLite3::Database.new @database_path
      File.chmod 0600, @database_path
      begin
        @db.execute 'drop table if exists host;'
        @db.execute <<-SQL
            create table host (
              id INT,
              name varchar(254)
            );
        SQL
        sync_host_table
      rescue
        File.unlink @database_path
        raise
      end
    end
    
    # Open a database connection. If the database does not exist, initialize it.
    def open_database
      if File.exist? @database_path
        validate_database
        @db = SQLite3::Database.new @database_path
      else
        initialize_database
      end
    end
    
    # Update the database based on the foreman_hook
    def execute_hook_action
      @rename = false
      name = @rec['host']['name']
      id = @rec['host']['id']
    
      case @action
      when 'create'
        sql = "insert into host (id, name) values (?, ?)"
        params = [id, name]
      when 'update'
        # Check if we are renaming the host
        @old_name = @db.get_first_row('select name from host where id = ?', id)[0]
        if @old_name.nil?
          warn 'received an update for a non-existent host'
        else
          @rename = @old_name != name
        end
        debug "checking for a rename: old=#{@old_name} new=#{name} rename?=#{@rename}"
    
        sql = 'update host set name = ? where id = ?'
        params = [name, id]
      when 'destroy'
        sql = 'delete from host where id = ?'
        params = [id]
      else
        raise ArgumentError, "unsupported action: #{ARGV[0]}"
      end
      debug "updating database; id=#{id} name=#{name} sql=#{sql}"
      stm = @db.prepare sql
      stm.bind_params *params
      stm.execute
    end
    
    # Check if the host has been renamed
    # @return true, if the host has been renamed
    def rename?
      @rename
    end
    
    def execute_rename_action
      raise 'old_name is nil' if @old_name.nil? 
      raise 'new_name is nil' if @rec['host']['name'].nil?
      cmd = @rename_hook_command + ' ' + @old_name + ' ' + @rec['host']['name']
      debug "Running the rename hook action: #{cmd}"
      open("| #{ cmd } > #{ @log_path } 2>&1", 'w+') do |subprocess|
        subprocess.write @rec.to_json
      end
      rc = $?.exitstatus
      warn "Hook exited with status #{rc}" if rc != 0
    end
    
    def parse_hook_data
      @action = ARGV[0]   # one of: create, update, destroy
      @rec = JSON.parse $stdin.read
      debug "action=#{@action} rec=#{@rec.inspect}"
    end
    
    def log_level=(level)
      @log_level = level
      @log.level = level
    end

    def open_logfile
      @log = Logger.new(@log_path, 10, 1024000)
      case @log_level
      when 'debug'
        @log.level = Logger::DEBUG
      when 'warn'
{'fraction_non_alphanumeric': 0.10402267072159248, 'fraction_numerical': 0.006497097041747305, 'mean_word_length': 2.487346348517715, 'pattern_counts': {'":': 0, '<': 11, '<?xml version=': 0, '>': 7, 'https://': 2, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 2, 'word_list_counts': {'cursed_substrings.json': 0, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'dir': 'git-commits-cleaned', 'id': '4490479', 'n_tokens_mistral': 4406, 'n_tokens_neox': 4300, 'n_words': 1453}