#!/opt/puppetlabs/puppet/bin/ruby
#
# Purpose
# -------
#
# This script is meant to be called by the %preun and %postttrans sections of
# the various SIMP Puppet module RPMs.
#
# The purpose of the script is to provide helper methods that correctly
# scaffold the system in such a way that all SIMP Puppet Modules can be
# installed via RPM to a single location and subsequently can be copied into
# the standard SIMP installation location based on the version of Puppet that
# is installed.
#
# Care is taken that, should the target directory be managed via 'git', this
# script will do nothing to harm the managed installation in any way. This
# ensures that the SIMP modules have maximum compatibility with the widest
# possible range of Puppet module best practices for management while ensuring
# that the RPM installations and upgrades can proceed in a seamless fashion
# over time.
#
# Should the 'simp' environment not be found, the script will simply exit
# without copying any files.
#
# Configuration
# -------------
#
# A configuration file may be placed at /etc/simp/adapter_config.yaml. The
# file must consist of proper YAML as demonstrated in the example below which
# lists the default options.
#
# Any configuration options that are not understood will be ignored.
#
# ```yaml
#   ---
#   # Target directory
#   # May be set to a fully qualified path or 'auto'
#   # If set to 'auto', the directory will be determined from puppet itself
#
#   target_directory : 'auto'
#
#   # Copy the RPM data to the target directory
#
#   copy_rpm_data : false
#
# ```
#

require 'facter'
require 'fileutils'
require 'yaml'
require 'optparse'
require 'ostruct'
require 'find'

# Make sure we can find the Puppet executables
ENV['PATH'] += ':/opt/puppetlabs/bin'

class SimpRpmHelper
  def initialize
    @program_name = File.basename(__FILE__)

    # A list of modules that should never be touched once installed
    @safe_modules = ['site']
  end

  def debug(msg)
    # SIMP RPMs do not enable debug when they call this script.  So, if
    # you want to debug an RPM problem with this script, comment out
    # the line below.
    return unless @options.debug
    msg.split("\n").each do |line|
      puts ">>>#{@program_name} DEBUG: #{line}"
    end
  end

  def info(msg)
    # When these messages get written out in an RPM upgrade, name of program
    # is helpful to end user
    puts "#{@program_name}: #{msg}"
  end

  # Get the Puppet configuration parameters currently in use
  def get_puppet_config
    system_config = %x{puppet config --section master print}

    config_hash = Hash.new

    system_config.each_line do |line|
      k,v = line.split('=')
      config_hash[k.strip] = v.strip
    end

    return config_hash
  end

  # Determine whether the passed path is under management by git or svn
  def is_managed?(path)
    # Short circuit if the directory is not present
    return false unless File.directory?(path)

    git = Facter::Core::Execution.which('git')
    svn = Facter::Core::Execution.which('svn')

    Dir.chdir(path) do
      if git
        %x{#{git} ls-files . --error-unmatch &> /dev/null}

        return true if $?.success?
      end

      if svn
        %x{#{svn} info &> /dev/null}

        return true if $?.success?
      end
    end

    return false
  end

  def parse_options(args)

    @options = OpenStruct.new
    @options.config_file = '/etc/simp/adapter_config.yaml'
    @options.preserve = false

    all_opts = OptionParser.new do |opts|
      opts.banner = "Usage: #{@program_name} [options]"

      opts.separator ''

      opts.on(
        '--rpm_dir PATH',
        'The directory into which the RPM source material is installed'
      ) do |arg|
        @options.rpm_dir = arg.strip
        @options.module_name = File.basename(@options.rpm_dir)
      end

      opts.on(
        '--rpm_section SECTION',
        'The section of the RPM from which the script is being called.',
        "    Must be one of 'pre', 'preun', 'postun', 'posttrans'"
      ) do |arg|
        @options.rpm_section = arg.strip
      end

      opts.on(
        '--rpm_status STATUS',
        'The status code passed to the RPM section'
      ) do |arg|
        @options.rpm_status = arg.strip
      end

      opts.on(
        '-f CONFIG_FILE',
        '--config CONFIG_FILE',
        'The configuration file to use.',
        "    Default: #{@options.config_file}"
      ) do |arg|
        @options.config_file = arg.strip
      end

      opts.on(
        '-p',
        '--preserve',
        "Preserve material in 'target_dir' that is not in 'rpm_dir'"
      ) do |arg|
        @options.preserve = true
      end

      opts.on(
        '-e',
        '--enforce',
        'If set, enforce the copy, regardless of the setting in the config file',
        '    Default: false'
      ) do |arg|
        @options.copy_rpm_data = true
      end

      opts.on(
        '-t DIR',
        '--target_dir DIR',
        "The subdirectory of #{simp_target_dir('')}",
        'into which to copy the materials.',
        "    Default: #{simp_target_dir.gsub(/#{simp_target_dir('')}/,'')}"
      ) do |arg|
        @options.target_dir = simp_target_dir(arg.strip)
      end

      opts.on(
        '-v',
        '--verbose',
        'Print out debug info when processing.'
      ) do
        @options.debug = true
      end

      opts.on(
        '-h',
        '--help',
        'Help Message'
      ) do
        puts opts
        @options.help_requested = true
      end
    end

    begin
      all_opts.parse!(args)
    rescue OptionParser::ParseError => e
      msg = "Error: #{e}\n\n#{all_opts}"
      raise(msg)
    end

    validate_options(all_opts.to_s)
  end

  # Process the config, validate the entries and do some munging
  # Sets @options hash.
  def process_config
    # Defaults
    config = {
      'target_directory' => 'auto',
      'copy_rpm_data'    => false
    }

    if File.exist?(@options.config_file)
      begin
        system_config = YAML.load_file(@options.config_file)
        if system_config
          config.merge!(system_config)
        end
      rescue
        raise("Error: Config file '#{@options.config_file}' could not be processed")
      end
    end

    if @options.copy_rpm_data.nil?
      @options.copy_rpm_data = (config['copy_rpm_data'].to_s == 'true')
    end

    if @options.target_dir.nil? && config['target_directory']
      if config['target_directory'] == 'auto'
        @options.target_dir = simp_target_dir
      else
        unless config['target_directory'][0].chr == '/'
          raise("Error: 'target_directory' in '#{@options.config_file}' must be an absolute path")
        end

        @options.target_dir = config['target_directory'].strip
      end
    end
  end

  def puppet_codedir
    # Figure out where the Puppet code should go
    # Puppet 4+
    code_dir = puppet_config['codedir']
    if !code_dir || code_dir.empty?
      code_dir = puppet_config['confdir']
    end

    return code_dir
  end

  def puppet_config
    unless @puppet_config
      @puppet_config = get_puppet_config
    end
    @puppet_config
  end


  def puppet_group
    puppet_config['group']
  end

  # Return the target installation directory
  def simp_target_dir(subdir=File.join('simp','modules'))
    install_target = puppet_codedir

    if install_target.empty?
      raise('Error: Could not find a Puppet code directory for installation')
    end

    install_target = File.join(install_target,'environments', subdir)

    return install_target
  end

  # Input Validation
  def validate_options(usage)
    return if @options.help_requested

    unless @options.rpm_dir
      raise("Error: 'rpm_dir' is required\n#{usage}")
    end

    unless @options.rpm_status
      raise("Error: 'rpm_status' is required\n#{usage}")
    end

    unless @options.rpm_section
      raise("Error: 'rpm_section' is required\n#{usage}")
    end

    # We allow 'post' for backward compatibility with SIMP RPMs that use
    # this, but copying over files in the 'post' during an upgrade is
    # problematic.  If the old package has files that are not in the new
    # package, these files will not be removed in the destination directory.
    # This is because during %post, the old package files have not yet
    # been removed from the source directory by RPM. So, the 'rsync'
    # operation copies over the OBE files from the old package.
    valid_rpm_sections = ['pre','post','preun','postun', 'posttrans']

    unless valid_rpm_sections.include?(@options.rpm_section)
      raise("Error: 'rpm_section' must be one of '#{valid_rpm_sections.join("', '")}'\n#{usage}")
    end

    if (@options.rpm_section == 'posttrans') || (@options.rpm_section == 'preun') || (@options.rpm_section == 'post')
      unless File.directory?(@options.rpm_dir)
        raise("Error: Could not find 'rpm_dir': '#{@options.rpm_dir}'")
      end
    end

    unless @options.rpm_status =~ /^\d+$/
      raise("Error: 'rpm_status' must be an integer\n#{usage}")
    end

  end

  def handle_install
    debug("Processing install, upgrade, or downgrade of #{@options.module_name}")
    if @safe_modules.include?(@options.module_name)
      # Make sure that we preserve anything in the safe modules on installation
      @options.preserve = true

      if @options.rpm_status == '2'
        # Short circuit on upgrading safe modules, just don't touch them!
        target_module_dir = File.join(@options.target_dir, @options.module_name)
        if File.directory?(target_module_dir)
          debug("Skipping upgrade of 'safe' module directory #{target_module_dir}")
          return
        end
      end
    end

    raise('Error: Could not determine puppet group') if puppet_group.empty?
    rsync = Facter::Core::Execution.which('rsync')
    raise("Error: Could not find 'rsync' command!") unless rsync

    # Create the directories, with the proper mode, all the way down
    dir_paths = @options.target_dir.split(File::SEPARATOR).reject(&:empty?)
    top_dir = File::SEPARATOR + dir_paths.shift
    unless File.directory?(top_dir)
      FileUtils.mkdir(top_dir, :mode => 0750)
      FileUtils.chown('root', puppet_group, top_dir)
    end

    orig_dir = Dir.pwd
    Dir.chdir(top_dir)
    dir_paths.each do |dir|
      unless File.directory?(dir)
        FileUtils.mkdir(dir, :mode => 0750)
        FileUtils.chown('root', puppet_group, dir)
      end

      Dir.chdir(dir)
    end
    Dir.chdir(orig_dir)

    cmd = %(#{rsync} -a --force)

    if @options.preserve
      cmd += %( --ignore-existing)
    else
      cmd += %( --delete)
    end

    cmd += %( --verbose) if @options.debug

    cmd += %( #{@options.rpm_dir} #{@options.target_dir})
    cmd += %( 2>&1)

    info("Copying '#{@options.module_name}' files into '#{@options.target_dir}'")
    debug("Executing: #{cmd}")
    output = %x{#{cmd}}
    debug("Output:\n#{output}")
    unless $?.success?
      raise(%(Error: Copy of '#{@options.module_name}' into '#{@options.target_dir}' using '#{cmd}' failed with the following error:\n    #{output.gsub("\n","\n    ")}))
    end

    FileUtils.chown_R(nil, "#{puppet_group}", @options.target_dir)
  end

  def handle_uninstall
    debug("Processing uninstall of #{@options.module_name}")
    # Play it safe, this needs to have at least 'environments/simp' in it!
    if @options.target_dir.split(File::SEPARATOR).reject(&:empty?).size < 3
      raise("Error: Not removing directory '#{@options.target_dir}' for safety")
    end

    if @safe_modules.include?(@options.module_name)
      target_module_dir = File.join(@options.target_dir, @options.module_name)
      debug("Skipping removal of 'safe' module directory #{target_module_dir}")
      return
    end

    info("Removing '#{@options.module_name}' files from '#{@options.target_dir}'")

    # Find out what we have
    ref_list = []
    Dir.chdir(@options.rpm_dir) do
      Find.find('.').each do |file|
        if File.symlink?(file)
          ref_list << file
          Find.prune
        end

        ref_list << file
      end
    end

    # Delete from the bottom up to clear out the directories first
    # before removing them
    ref_list.reverse!
    ref_list.map{|x| x.sub!(/^./, @options.module_name)}

    # Only delete items that are in the reference repo
    Dir.chdir(@options.target_dir) do
      ref_list.each do |to_rm|
        if File.symlink?(to_rm)
          debug("Removing symlink #{to_rm}")
          FileUtils.rm_f(to_rm)
        elsif File.directory?(to_rm) && (Dir.entries(to_rm).delete_if {|dir|
            dir == '.' || dir == '..'}.size == 0)
          debug("Removing directory #{to_rm}")
          FileUtils.rmdir(to_rm)
        elsif File.exist?(to_rm)
          debug("Removing file #{to_rm}")
          FileUtils.rm_f(to_rm)
        end
      end
    end
  end

  def run(args)
    parse_options(args)
    return 0 if @options.help_requested

    process_config
    debug("Running with config=#{@options.to_s}")

    # If the target directory is managed, we're done
    target_module_dir = File.join(@options.target_dir, @options.module_name)
    unless is_managed?(target_module_dir) || !@options.copy_rpm_data

      debug("Processing unmanaged target directory #{target_module_dir}")

      if (@options.rpm_section == 'posttrans') || (@options.rpm_section == 'post')
        # A regular installation, upgrade or downgrade
        # This *should* happen in the RPM %posttrans, but we allow this to
        # occur in the %post for backward compatibility with SIMP RPMs that
        # erroneously try to affect a copy in the %post. (Copying over the
        # files in the RPM %post during an upgrade/downgrade is problematic.
        # If the old package has files that are not in the new package,
        # these files will not yet have been removed in the source
        # directory, and thus end up in the target directory.)
        handle_install
      elsif @options.rpm_section == 'preun' && @options.rpm_status == '0'
        # A regular uninstall
        # This needs to happen *before* RPM removes the files (%preun with
        # status 0), since we need to compare with what's on disk to undo
        # the copy done during the RPM install via handle_install()
        handle_uninstall
      end
    end
    return 0
  rescue RuntimeError => e
    $stderr.puts(e)
    return 1
  rescue Exception => e
    $stderr.puts(e)
    e.backtrace.first(10).each{|l| $stderr.puts l }
    return 1
  end
end

if __FILE__ == $0
  helper = SimpRpmHelper.new
  exit helper.run(ARGV)
end