#!/usr/bin/env ruby
# A tool to create configuration files from a variety of sources, particularly 
# useful for Docker containers. See https://github.com/markround/tiller for 
# examples and documentation.
#
# Named from the first ship-building (Docker) related term I could find that
# didn't have an existing gem named after it!
#
# Mark Dastmalchi-Round <github@markround.com>

require 'erb'
require 'ostruct'
require 'yaml'
require 'fileutils'
require 'optparse'
require 'pp'
require 'json'
require 'socket'
require 'tiller/api'
require 'tiller/defaults'
require 'tiller/loader'
require 'tiller/options'
require 'tiller/util'
require 'tiller/templatesource'
require 'tiller/datasource'
require 'tiller/logger'
require 'digest/md5'
require 'tiller/render'
require 'tiller/kv'
require 'tiller/version'

EXIT_SUCCESS = 0

# And we're on our way...
module Tiller

  puts "tiller v#{VERSION} (https://github.com/markround/tiller) <github@markround.com>"
  if RUBY_VERSION < SUPPORTED_RUBY_VERSION
    puts "Warning : Support for Ruby versions < #{SUPPORTED_RUBY_VERSION} is deprecated."
    puts "          See http://tiller.readthedocs.io/en/latest/requirements/"
  end

  class << self
    attr_accessor :config, :log, :templates, :tiller
  end

  Tiller::config = parse_options(Tiller::Defaults)
  Tiller::log = Tiller::Logger.new

  log.debug("Executable: #{__FILE__}")

  # Add tiller_lib to the LOAD PATH so we can pull in user-defined plugins
  $LOAD_PATH.unshift(config[:tiller_lib]) unless $LOAD_PATH.include?(config[:tiller_lib])

  # Load the common YAML configuration files
  begin
    config_loaded = false

    # Is there a common.yaml ? If so, load that first
    common_file = File.join(config[:tiller_base], 'common.yaml')
    if File.exists?(common_file)
      config.merge!(YAML.load(open(common_file)))
      config_loaded = true
    end

    # Load and deep merge anything under config.d if present
    config_d_dir =  File.join(config[:tiller_base], 'config.d')
    Dir.glob(File.join(config_d_dir, '**', '*.yaml')).sort.each do |f|
      log.info("Loading config file #{f}")
      config.deep_merge!(YAML.load(open(f)))
      config_loaded = true
    end

  rescue StandardError => e
    abort "Error : Could not open common configuration files!\n#{e}"
  end

  unless config_loaded
    abort "Error: No configuration files present!"
  end

  # Check for keys only present in v2 format (everything in one common.yaml)
  if [ 'environments' , 'defaults'].any? { |k| config.has_key?(k) }
    log.info("Using common.yaml v2 format configuration file")
    config[:config_version] = 2
  else
    config[:config_version] = 1
  end

  # Set the environment if not already done through ENV or -e flag
  config[:environment] = config['default_environment'] if config[:environment].nil?

  log.info("Using configuration from #{config[:tiller_base]}")
  log.info("Using plugins from #{config[:tiller_lib]}/tiller")
  log.info("Using environment #{config[:environment]}")

  # Now load all our plugins
  data_classes      = loader(DataSource, config['data_sources'])
  template_classes  = loader(TemplateSource, config['template_sources'])

  # dynamic_values in top-level config (https://github.com/markround/tiller/issues/58)
  # We create a temporary copy of our config hash, and iterate over it, fetching values from each datasource in turn
  # and then merging the values back into the main config hash as we go along.
  # Due to needing binding functions only present in Ruby >= 2.1.0, this feature is not present on older Ruby versions.
  # See See http://tiller.readthedocs.io/en/latest/requirements/ for Ruby support policy and background.
  if config.assoc('dynamic_values') && RUBY_VERSION >= "2.1.0"
    log.debug('Parsing top-level values for ERb syntax')
    # Deep copy for our temp config
    temp_config = Marshal.load(Marshal.dump(config))
    data_classes.each do |data_class|
      temp_config.deep_traverse do |path,value|
        # We skip anything under environments block (Unless it's the "common" over-ride block) as these
        # may contain values we want to replace with template-specific values later on.
        next if path.include?('environments') and ! path.include?('common')

        if value.is_a?(String) && value.include?('<%')
          begin
            parsed_value = Tiller::render(value, direct_render: true, namespace: data_class.new.global_values)
            log.debug("Parsed ERb of #{path.join('/')} as #{parsed_value}", dedup: false)
            config.deep_merge!((path + [parsed_value]).reverse.reduce { |s,e| { e => s } })
          rescue NameError => e
            # This happens if there is no value provided by the currently active datasource. If so,
            # we simply catch the error and proceed without merging anything.
          end
        end
      end
    end
  end

  log.info('Template sources loaded ' + template_classes.to_s)
  log.info('Data sources loaded ' + data_classes.to_s)
  if (config.key?('helpers'))
    helper_modules    = helper_loader(config['helpers'])
    log.info('Helper modules loaded ' + helper_modules.to_s)
  end

  log.debug("Dynamic values specified. Will parse all values as ERb.") if config.assoc('dynamic_values')

  # We now don't actually use the global_values hash for anything when constructing the templates (as they can be
  # over-ridden by template values), but it's here to keep compatibility with the v1 API.
  global_values = { 'environment' => config[:environment] }

  log.debug("Loading data classes.")
  data_classes.each do |data_class|
    log.debug("Loading data class #{data_class}.")
    # Now need to see if any of the common.yaml values have been over-ridden by a datasource
    # e.g. environment-specific execs and so on. We do this first so that connection strings
    # to datasources (e.g. zookeeper) can be included in the config before we obtain any
    # values.
    config.merge!(data_class.new.common) do |key, old, new|
      warn_merge(key, old, new, 'common', data_class.to_s)
    end

    # Merge for the sake of the v1 API
    global_values.merge!(data_class.new.global_values)
  end

  # Get all Templates for the given environment
  Tiller::templates = {}
  template_classes.each do |template_class|
    ts = template_class.new
    ts.templates.each do |t|
      templates[t] = ts.template(t)
    end
  end

  log.info("Available templates : #{templates.keys}")

  # Now we go through each template we've identified, and get the
  # values for each one.
  all_templates       = {}
  skipped_templates   = 0
  updated_templates   = 0
  pids                = []

  templates.each do |template, _content|
    log.debug("Rendering template #{template}")

    # We add in 'environment' to start with as it's very useful for all
    # templates.
    Tiller::tiller = { 'environment' => config[:environment] }
    target_values  = {}

    # Now we add to the 'tiller' hash with values from each DataSource, warning if we
    # get duplicate values.
    data_classes.each do |data_class|
      dc = data_class.new

      # First take the global values from the datasource
      tiller.tiller_merge!(data_class.new.global_values) do |key, old, new|
        warn_merge(key, old, new, 'data', data_class.to_s)
      end

      # Then merge template values over the top of them
      if dc.values(template) != nil
        tiller.tiller_merge!(dc.values(template)) do |key, old, new|
          warn_merge(key, old, new, 'data', data_class.to_s)
        end
      end

      # Now get target_values (where the file should be installed to,
      # permissions and so on)
      target_values.tiller_merge!(dc.target_values(template)) do |key, old, new|
        warn_merge(key, old, new, 'target', data_class.to_s)
      end

      # Dynamic config stuff, allows us to use ERb syntax inside configuration.
      #
      # NOTE (Lastline comment): I believe this `if` belongs outside of the current loop. If more
      # than one data-source is defined, the `target_values` get evaluated with only the first data-
      # source as input. As we render "target values" on top of each other, overwriting the dynamic
      # values before a correct evaluation has been done
      if config.assoc('dynamic_values')
        # Need to recursively walk the hash, looking for ERb syntax
        tiller.deep_traverse do |path,value|
          if value.is_a?(String) && value.include?('<%')
            log.debug("Found ERb syntax for #{value} at #{path}")
            parsed_value = Tiller::render(value, direct_render: true)
            # Proper Ruby voodoo here.
            # See http://stackoverflow.com/questions/19304135/deep-nest-a-value-into-a-hash-given-a-path-array for
            # explanation.
            tiller.deep_merge!((path + [parsed_value]).reverse.reduce { |s,e| { e => s } })
          end
        end
        target_values.each do |key ,value|
          if value.is_a?(String) && value.include?('<%')
            log.debug("Found ERb syntax for target value #{key}:#{value}}")
            target_values[key] = Tiller::render(value, direct_render: true)
          end
        end
      end
    end

    # If our data source returned no values (e.g. we don't build this template
    # for this environment), we move onto the next one.
    if target_values.empty?
      log.debug("Skip building template #{template}: opt-out (no target data)")
      next
    end

    # a template can be rendered into multiple target files - as specified via the
    # "target" property (default tiller behavior) and/or the "targets" property (Lastline
    # extension) in the environment specification
    #
    # Collect the two ways of configuring into a single one
    targets = {}
    if target_values.has_key?('target')
     if target_values['target'].is_a?(String)
        targets[target_values['target']] = Marshal.load(Marshal.dump(target_values))
      else
        log.warning("Invalid environment target #{target_values['target']}: must be a string")
      end
    end
    if target_values.has_key?('targets')
      if target_values['targets'].is_a?(Array)
        target_values['targets'].each do |target_entry|
          target_filename = target_entry['target']
          # first we populate the target with the default options common to all entries
          targets[target_filename] = Marshal.load(Marshal.dump(target_values))
          # then we override any properties with the ones for the element in the list
          targets[target_filename].deep_merge!(target_entry)
        end
      else
        log.warning("Invalid environment targets #{target_values['targets']}: must be a list")
      end
    end

    # Now, we build the template
    log.info("Building template #{template} (#{targets.length} targets)")

    targets.each do |target_filename, target_entry|
      # To give the template a bit of context of what we are rendering, we pass in the
      # "target_data" as global variable to the template code. Before we do so, we remove any
      # potential "target" and "targets" properties, as we have copied/normalized this above
      # ... it would just confuse inside the template code
      target_entry['target'] = target_filename
      target_entry.delete('targets') if target_entry.has_key?('targets')
      tiller['tiller_template_data'] = target_entry
      tiller['tiller_template'] = template

      # Use our re-usable render helper
      parsed_template = Tiller::render(template)

      # reset the loop-specific data
      tiller.delete('tiller_template_data')
      tiller.delete('tiller_template')

      # Write the template, and also create the directory path if it
      # doesn't exist.
      target_path = File.dirname(target_filename)
      FileUtils.mkdir_p(target_path) unless File.directory?(target_path)

      # MD5 checksum of templates
      if config['md5sum'] && File.exists?(target_filename)
        template_md5 = Digest::MD5.hexdigest(parsed_template)
        log.debug("MD5 of #{template} is #{template_md5}")
        file_md5 = Digest::MD5.hexdigest(File.read(target_filename))
        log.debug("MD5 of #{target_filename} is #{file_md5}")

        if template_md5 == file_md5
          log.info("Content unchanged for #{template}, not writing anything")
          skipped_templates += 1
          next
        end
      end

      target = open(target_filename, 'w')
      target.print(parsed_template)
      target.close

      updated_templates += 1

      # config is redundant in target_values, remove it for the final status hash.
      #
      # NOTE: The Lastline extension to allow rendering a template into multiple targets changes
      # the structure of the "all_templates" hash from being a plain hash into a nested one
      if ! all_templates.has_key?(template)
        all_templates[template] = {}
      end
      all_templates[template][target_filename] = {
          'merged_values' => tiller,
          'target_values' => target_entry.reject{|k,_v| k=='config'}
      }

      # Set permissions if we are running as root
      if Process::Sys.geteuid == 0
        log.info("Setting ownership/permissions on #{target_filename}")
        if target_entry.key?('perms')
          FileUtils.chmod(target_entry['perms'], target_filename)
        end
        # Don't need to check for the presence of these, as they're ignored
        # if they are null.
        FileUtils.chown(target_entry['user'], target_entry['group'],
                        target_filename)
      else
        log.info('Not running as root, so not setting ownership/permissions on ' \
          "#{target_filename}")
      end

      # Exec on write
      if target_entry.key?('exec_on_write')
        if ! target_entry['exec_on_write'].is_a?(Array)
          log.warn("Warning: exec_on_write for template #{template} is not in array format")
        else
          if config[:no_exec] == true
            log.info("no-exec option set, so not running exec_on_write for this template")
          else
            eow_pid=launch(target_entry['exec_on_write'])
            pids.push(eow_pid)
            log.info("exec_on_write process for #{template} forked with PID #{eow_pid}")
          end
        end
      end
    end

  end

  if config['md5sum']
    log.info("[#{updated_templates}/#{templates.size}] templates written, [#{skipped_templates}] skipped with no change")
  end

  puts 'Template generation completed'

  # Final status structure for API
  tiller_api_hash = {'config' => config, 'global_values' => global_values, 'templates' => all_templates}

  if config['api_enable']
    Thread.start{ tiller_api(tiller_api_hash) }
  end

  # Override the exec if run with -x (see options.rb)
  if config.has_key?(:alt_exec)
    log.info("Overriding exec parameter [#{config['exec']}] with [#{config[:alt_exec]}]")
    config['exec'] = config[:alt_exec]
  end

  # If no templates were generated and md5sum_noexec is enabled, stop here.
  if config['md5sum'] && config['md5sum_noexec'] && skipped_templates == templates.size
    log.info("No templates written, stopping without exec")
    exit EXIT_SUCCESS
  end

  if config[:no_exec] == false && config.key?('exec')
    # All templates created, so let's start the replacement process
    puts "Executing #{config['exec']}..."

    # Spawn and wait so API can continue to run
    child_pid = launch(config['exec'])
    pids.push(child_pid)

    log.info("Child process forked with PID #{child_pid}")

    # Catch signals and send them on to the child processes
    [ :INT, :TERM, :HUP, :QUIT, :USR1, :USR2, :WINCH ].each do |sig|
      Signal.trap(sig) do
        pids.each { |p|  signal(sig, p, :verbose => config[:verbose])}
      end
    end

    # Wait for all PIDs
    main_exit_status = 0
    while pids.length >= 1 do
      collected_pid = Process.wait
      exit_status = ($?.exitstatus == nil) ? 0 : $?.exitstatus
      if collected_pid == child_pid
        log.info("Main child process with PID #{collected_pid} exited with status #{exit_status}")
        main_exit_status = exit_status
      else
        log.info("exec_on_write process with PID #{collected_pid} exited with status #{exit_status}")
      end
      pids.delete(collected_pid)
    end

    log.info("Child process exited with status #{main_exit_status}")
    log.info('Child process finished, Tiller is stopping.')
    exit exit_status

  end

end
