# The client for interacting with the puppetmaster config server.
require 'sync'
require 'timeout'
require 'puppet/network/http_pool'
require 'puppet/util'
require 'securerandom'

class Puppet::Configurer
  require 'puppet/configurer/fact_handler'
  require 'puppet/configurer/plugin_handler'
  require 'puppet/configurer/downloader_factory'

  include Puppet::Configurer::FactHandler

  # For benchmarking
  include Puppet::Util

  attr_reader :compile_time, :environment

  # Provide more helpful strings to the logging that the Agent does
  def self.to_s
    "Puppet configuration client"
  end

  def execute_postrun_command
    execute_from_setting(:postrun_command)
  end

  def execute_prerun_command
    execute_from_setting(:prerun_command)
  end

  # Initialize and load storage
  def init_storage
      Puppet::Util::Storage.load
      @compile_time ||= Puppet::Util::Storage.cache(:configuration)[:compile_time]
  rescue => detail
    Puppet.log_exception(detail, "Removing corrupt state file #{Puppet[:statefile]}: #{detail}")
    begin
      Puppet::FileSystem.unlink(Puppet[:statefile])
      retry
    rescue => detail
      raise Puppet::Error.new("Cannot remove #{Puppet[:statefile]}: #{detail}", detail)
    end
  end

  def initialize(factory = Puppet::Configurer::DownloaderFactory.new)
    @running = false
    @splayed = false
    @environment = Puppet[:environment]
    @transaction_uuid = SecureRandom.uuid
    @handler = Puppet::Configurer::PluginHandler.new(factory)
  end

  # Get the remote catalog, yo.  Returns nil if no catalog can be found.
  def retrieve_catalog(query_options)
    query_options ||= {}
    # First try it with no cache, then with the cache.
    unless (Puppet[:use_cached_catalog] and result = retrieve_catalog_from_cache(query_options)) or result = retrieve_new_catalog(query_options)
      if ! Puppet[:usecacheonfailure]
        Puppet.warning "Not using cache on failed catalog"
        return nil
      end
      result = retrieve_catalog_from_cache(query_options)
    end

    return nil unless result

    convert_catalog(result, @duration)
  end

  # Convert a plain resource catalog into our full host catalog.
  def convert_catalog(result, duration)
    catalog = result.to_ral
    catalog.finalize
    catalog.retrieval_duration = duration
    catalog.write_class_file
    catalog.write_resource_file
    catalog
  end

  def get_facts(options)
    if options[:pluginsync]
      remote_environment_for_plugins = Puppet::Node::Environment.remote(@environment)
      download_plugins(remote_environment_for_plugins)
    end

    facts_hash = {}
    if Puppet::Resource::Catalog.indirection.terminus_class == :rest
      # This is a bit complicated.  We need the serialized and escaped facts,
      # and we need to know which format they're encoded in.  Thus, we
      # get a hash with both of these pieces of information.
      #
      # facts_for_uploading may set Puppet[:node_name_value] as a side effect
      facts_hash = facts_for_uploading
    end
    facts_hash
  end

  def prepare_and_retrieve_catalog(options, query_options)
    # set report host name now that we have the fact
    options[:report].host = Puppet[:node_name_value]

    unless catalog = (options.delete(:catalog) || retrieve_catalog(query_options))
      Puppet.err "Could not retrieve catalog; skipping run"
      return
    end
    catalog
  end

  # Retrieve (optionally) and apply a catalog. If a catalog is passed in
  # the options, then apply that one, otherwise retrieve it.
  def apply_catalog(catalog, options)
    report = options[:report]
    begin
      report.configuration_version = catalog.version

      benchmark(:notice, "Applied catalog") do
        catalog.apply(options)
      end
    ensure
      report.finalize_report
    end
    report
  end

  # The code that actually runs the catalog.
  # This just passes any options on to the catalog,
  # which accepts :tags and :ignoreschedules.
  def run(options = {})
    pool = Puppet::Network::HTTP::Pool.new(Puppet[:http_keepalive_timeout])
    begin
      Puppet.override(:http_pool => pool) do
        run_internal(options)
      end
    ensure
      pool.close
    end
  end

  def run_internal(options)
    # We create the report pre-populated with default settings for
    # environment and transaction_uuid very early, this is to ensure
    # they are sent regardless of any catalog compilation failures or
    # exceptions.
    options[:report] ||= Puppet::Transaction::Report.new("apply", nil, @environment, @transaction_uuid)
    report = options[:report]
    init_storage

    Puppet::Util::Log.newdestination(report)

    begin
      unless Puppet[:node_name_fact].empty?
        query_options = get_facts(options)
      end

      # We only need to find out the environment to run in if we don't already have a catalog
      unless options[:catalog]
        begin
          if node = Puppet::Node.indirection.find(Puppet[:node_name_value],
              :environment => Puppet::Node::Environment.remote(@environment),
              :configured_environment => Puppet[:environment],
              :ignore_cache => true,
              :transaction_uuid => @transaction_uuid,
              :fail_on_404 => true)

            # If we have deserialized a node from a rest call, we want to set
            # an environment instance as a simple 'remote' environment reference.
            if !node.has_environment_instance? && node.environment_name
              node.environment = Puppet::Node::Environment.remote(node.environment_name)
            end

            if node.environment.to_s != @environment
              Puppet.warning "Local environment: \"#{@environment}\" doesn't match server specified node environment \"#{node.environment}\", switching agent to \"#{node.environment}\"."
              @environment = node.environment.to_s
              report.environment = @environment
              query_options = nil
            end
          end
        rescue StandardError => detail
          Puppet.warning("Unable to fetch my node definition, but the agent run will continue:")
          Puppet.warning(detail)
        end
      end

      current_environment = Puppet.lookup(:current_environment)
      local_node_environment =
      if current_environment.name == @environment.intern
        current_environment
      else
        Puppet::Node::Environment.create(@environment,
                                         current_environment.modulepath,
                                         current_environment.manifest,
                                         current_environment.config_version)
      end
      Puppet.push_context({:current_environment => local_node_environment}, "Local node environment for configurer transaction")

      query_options = get_facts(options) unless query_options
      query_options[:transaction_uuid] = @transaction_uuid
      query_options[:configured_environment] = Puppet[:environment]

      unless catalog = prepare_and_retrieve_catalog(options, query_options)
        return nil
      end

      # Here we set the local environment based on what we get from the
      # catalog. Since a change in environment means a change in facts, and
      # facts may be used to determine which catalog we get, we need to
      # rerun the process if the environment is changed.
      tries = 0
      while catalog.environment and not catalog.environment.empty? and catalog.environment != @environment
        if tries > 3
          raise Puppet::Error, "Catalog environment didn't stabilize after #{tries} fetches, aborting run"
        end
        Puppet.warning "Local environment: \"#{@environment}\" doesn't match server specified environment \"#{catalog.environment}\", restarting agent run with environment \"#{catalog.environment}\""
        @environment = catalog.environment
        report.environment = @environment

        query_options = get_facts(options)
        query_options[:transaction_uuid] = @transaction_uuid
        query_options[:configured_environment] = Puppet[:environment]

        return nil unless catalog = prepare_and_retrieve_catalog(options, query_options)
        tries += 1
      end

      execute_prerun_command or return nil
      apply_catalog(catalog, options)
      report.exit_status
    rescue => detail
      Puppet.log_exception(detail, "Failed to apply catalog: #{detail}")
      return nil
    ensure
      execute_postrun_command or return nil
    end
  ensure
    # Between Puppet runs we need to forget the cached values.  This lets us
    # pick up on new functions installed by gems or new modules being added
    # without the daemon being restarted.
    $env_module_directories = nil

    Puppet::Util::Log.close(report)
    send_report(report)
    Puppet.pop_context
  end
  private :run_internal

  def send_report(report)
    puts report.summary if Puppet[:summarize]
    save_last_run_summary(report)
    Puppet::Transaction::Report.indirection.save(report, nil, :environment => Puppet::Node::Environment.remote(@environment)) if Puppet[:report]
  rescue => detail
    Puppet.log_exception(detail, "Could not send report: #{detail}")
  end

  def save_last_run_summary(report)
    mode = Puppet.settings.setting(:lastrunfile).mode
    Puppet::Util.replace_file(Puppet[:lastrunfile], mode) do |fh|
      fh.print YAML.dump(report.raw_summary)
    end
  rescue => detail
    Puppet.log_exception(detail, "Could not save last run local report: #{detail}")
  end

  private

  def execute_from_setting(setting)
    return true if (command = Puppet[setting]) == ""

    begin
      Puppet::Util::Execution.execute([command])
      true
    rescue => detail
      Puppet.log_exception(detail, "Could not run command from #{setting}: #{detail}")
      false
    end
  end

  def retrieve_catalog_from_cache(query_options)
    result = nil
    @duration = thinmark do
      result = Puppet::Resource::Catalog.indirection.find(Puppet[:node_name_value],
        query_options.merge(:ignore_terminus => true, :environment => Puppet::Node::Environment.remote(@environment)))
    end
    Puppet.notice "Using cached catalog"
    result
  rescue => detail
    Puppet.log_exception(detail, "Could not retrieve catalog from cache: #{detail}")
    return nil
  end

  def retrieve_new_catalog(query_options)
    result = nil
    @duration = thinmark do
      result = Puppet::Resource::Catalog.indirection.find(Puppet[:node_name_value],
        query_options.merge(:ignore_cache => true, :environment => Puppet::Node::Environment.remote(@environment), :fail_on_404 => true))
    end
    result
  rescue StandardError => detail
    Puppet.log_exception(detail, "Could not retrieve catalog from remote server: #{detail}")
    return nil
  end

  def download_plugins(remote_environment_for_plugins)
    @handler.download_plugins(remote_environment_for_plugins)
  end
end
