#!/opt/chef/embedded/bin/ruby

# vim: syntax=ruby:expandtab:shiftwidth=2:softtabstop=2:tabstop=2

# Copyright 2013-present Facebook
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

require 'chef/version'
require 'date'
require 'English'
require 'fileutils'
require 'logger'
require 'open3'
require 'optparse'
require 'socket'
require 'mixlib/config'
require 'mixlib/log'
require 'mixlib/shellout'
require 'rubygems'

# We use comments on end blocks to tell what that end statement is ending
# for sanity sake. This rubocop rule doesn't like this style.
# rubocop:disable Style/CommentedKeyword

def quit(message, exitcode = 1)
  Chefctl.logger.error(message)
  exit exitcode
end

module Chefctl
  # Default config file path.
  DEFAULT_CONFIG = '/etc/chefctl-config.rb'.freeze

  # Buffer size to use when reading chef output from stdout.
  BUFFER_SIZE = 1024

  # This is the exit code for chefctl when chef-client fails.
  # This is used downstream to determine between chef-client and chefctl
  # failures. (i.e. anything that isn't this number is a chefctl failure)
  CHEFCLIENT_FAILURE = 4 # chosen by fair dice roll.

  # let's be us, unless someone asked us to be someone else
  @program_name = 'chefctl'
  @logger = nil
  @lib = nil
  @log_file = nil

  def self.program_name=(v)
    @program_name = File.basename(v)
    @logger.progname = @program_name if @logger
  end

  def self.program_name
    @program_name
  end

  class InternalLogger
    extend Mixlib::Log
  end

  def self.init_logger(fout = nil)
    # !!assign to the class, not an instance of the class!!
    @logger = InternalLogger
    # default behavior is STDERR with level :warn
    if fout
      @log_file = File.open(fout, 'w')
      @logger.loggers << Logger.new(@log_file)
    end
    @logger.loggers.each do |log|
      log.formatter = proc do |severity, datetime, progname, msg|
        progname ||= program_name
        msg = msg[:msg] if msg.is_a?(Hash)
        "[#{datetime}] #{severity} #{progname}: #{msg}\n"
      end
      log.progname = program_name
    end
    @logger.level = :info
  end

  def self.log_file
    @log_file
  end

  def self.flush_logger
    # flush the file backing our logger object
    @log_file.flush if @log_file
  end

  def self.logger
    init_logger unless @logger
    @logger
  end

  def self.close_logger
    @log_file.close
    @log_file = nil
    @logger = nil
  end

  def self.lib
    unless @lib
      # In the future, this should auto-determine that platform type
      # and use the correct Chefctl::Lib{platform} class.
      if Gem.win_platform?
        @lib = Chefctl::Lib::Windows.new
      else
        @lib = Chefctl::Lib::Linux.new
      end
    end
    @lib
  end

  module Config
    extend Mixlib::Config

    # Allow the chef run to provide colored output.
    color false

    # Whether or not chefctl should provide verbose output.
    verbose false

    # The chef-client process to use. Could be string or array of strings
    # to specify the ruby interpreter, which is needed for Windows if
    # windows_subshell is false.
    chef_client '/opt/chef/bin/chef-client'

    # Whether or not chef-client should provide debug output.
    debug false

    trace false

    # Default options to pass to chef-client.
    chef_options ['--no-fork']

    # Whether or not to provide human-readable output.
    human false

    # If set, ignore the splay and stop pending chefctl processes before
    # running. This is intended for interactive runs of chef
    # (i.e. started by a human).
    immediate false

    # The lock file to use for chefctl.
    lock_file '/var/lock/subsys/chefctl'

    # How long to wait for the lock to become available.
    lock_time 1800

    # Directory where per-run chef logs should be placed.
    log_dir '/var/chef/outputs'

    # If set, will not copy chef log to stdout.
    quiet false

    # The default splay to use. Ignored if `immediate` is set to true.
    splay 870

    # How many chef-client retries to attempt before failing.
    # See Chefctl::Plugin.rerun_chef?
    max_retries 1

    # where to find certs and configs
    client_config_dir '/etc/chef'

    # The testing timestamp.
    # See https://github.com/facebook/taste-tester
    testing_timestamp '/etc/chef/test_timestamp'

    # Whether or not to run chef in whyrun mode.
    whyrun false

    # The default location of the chefctl plugin file.
    plugin_path '/etc/chef/chefctl_hooks.rb'

    # The default PATH environment variable to use for chef-client.
    # Should be unset for Windows if `windows_subshell` is set to false
    path %w{
      /bin
      /sbin
      /usr/sbin
      /usr/bin
    }

    # Whether or not to symlink output files for chef.cur.out and chef.last.out
    symlink_output true

    # Environment variables to pass-through from the environment where chefctl
    # is invoked. Environment variables that aren't listed here are removed.
    # Note that PATH and HOSTNAME are set by `path` and `hostname` in Config
    # and Plugin, respectively, so including them here does nothing.
    passthrough_env %w{
      CHEF_LICENSE
      HOME
      LOGNAME
      PWD
      SUDO_USER
      USER
      XAR_MOUNT_SEED
    }

    # TODO(yottatsa): this option is deprecated
    # Process.spawn works fine for all platforms. This option meant to preserve
    # old Windows behaviour, lack of logging now, and shouldn't be used.
    windows_subshell false

    # Priority level to set the chef-client process to. This was mostly tested
    # on a Windows laptop and may not work as expected elsewhere, so you should
    # do proper testing prior to changing client configurations.
    # Linux/mac use niceness values, Windows uses values set in windows APIs.
    # Niceness values range from -20 (most favorable to the process) to
    # 19 (least favorable to the process).
    # Windows values are as follows: "Idle" => 64, "BelowNormal" => 16384,
    # "Normal" => 32, "AboveNormal" => 32768, "High" => 128, "Realtime" => 256
    priority nil
  end

  # Chefctl plugins are used to define custom behavior for chefctl.
  # A fixed set of interaction points are defined below and the corresponding
  # functions are called at the appropriate time during a chefctl run.
  module Plugin
    @plugin_module = nil
    @plugin = nil

    ###
    # Default behavior for plugins
    ###

    # Called during command line option parsing.
    # Allows a plugin to define additional command-line arguments.
    # Parameters:
    # - parser: an OptionParser object
    # The return value is ignored.
    def cli_options(parser); end

    # Called between command line parsing, and acquiring the lock.
    # This hook is intended to be used to modify config options via
    # Chefctl::Config, or do other setup items for the hooks.
    # Setup items for the chef run should be placed in pre_run, since
    # pre_start is called before the lock is acquired.
    # The return value is ignored.
    def pre_start; end

    # Gets the hostname of the machine. This sets the HOSTNAME environment
    # variable for the chef-client process.
    # Returns the hostname of the machine as a string.
    def hostname
      Socket.gethostname
    end

    # Validates the authenticity of chef certificates, regenerating
    # them if necessary.
    # The return value is ignored.
    def generate_certs
      client_prod_cert = "#{Chefctl::Config.client_config_dir}/client-prod.pem"
      if File.zero?(client_prod_cert)
        Chefctl.logger.info('zero-byte client pem found, removing')
        File.unlink(client_prod_cert)
      end
    end

    # Called after the lock is acquired, before the chef run is started.
    # Parameters:
    # - output is the path to the log file for the chef run
    # The return value is ignored.
    def pre_run(_output); end

    # Called after the final chef run completes, before the lock is released.
    # Normally this would be after the first (and only) chef run, but
    # re-runs can be triggered by the `rerun_chef?` hook, in which case
    # this hook is called exactly once after the final chef run.
    # Parameters:
    # - output is the path to the log file for the chef run.
    # - chef_exitcode is the exit code of the final chef-client process.
    #   (>0 on failure)
    # The return value is ignored.
    def post_run(_output, _chef_exitcode); end

    # Check whether or not another chef run is required.
    # Parameters:
    # - output is the path to the log file for the chef run.
    # - chef_exitcode is the chef-client exit code. (>0 on failure)
    # Returns a boolean indicating if another chef run should be performed.
    # This hook is called at most `Chefctl::Config.max_retries` times.
    # With the default value of 1 retry, this hook is not called a second time,
    # regardless of the result of the chef re-run.
    def rerun_chef?(_output, _chef_exitcode)
      false
    end

    ###
    # Helpers
    ###

    # Short-hand helper that returns a meaningful logger.
    def logger
      Chefctl.logger
    end

    # Returns an object with the registered plugin included in it.
    def self.get_plugin
      unless @plugin
        # we can't look up class variables in the class block below,
        # so we 'alias' it as a local variable
        m = @plugin_module

        # concrete is the class for our plugin object.
        # It's just an empty class which includes the modules for our plugin.
        concrete = Class.new do
          # Include the base Plugin, which includes the defaults for plugin
          # behaviors.
          include Plugin

          if m
            Chefctl.logger.debug("Including registered plugin #{m}")
            include m
          end
        end
        Chefctl.const_set('ConcretePlugin', concrete)
        @plugin = concrete.new
      end
      @plugin
    end

    # Registers a module as the plugin.
    def self.register(mod)
      if @plugin_module
        Chefctl.logger.warn("Plugin #{@plugin_module} already registered. " +
                            "Using new plugin #{mod} instead.")
      end
      @plugin_module = mod
    end

    # Loads the plugin from a file
    def self.load_file(filename)
      if filename
        filename = File.expand_path(filename)
        if File.exist? filename
          Chefctl.logger.debug("Loading plugin at #{filename}.")
          begin
            require_relative filename
          rescue LoadError => e
            Chefctl.logger.debug("While loading #{filename} got error: #{e}")
            Chefctl.logger.warn("Failed to load plugin #{filename}. Failing!")
            raise
          end
        else
          Chefctl.logger.info("Plugin file not found at #{filename}. Ignoring.")
        end
      else
        Chefctl.logger.info('Plugin file not defined. Ignoring.')
      end
    end
  end # class Plugin

  # Platform-independent helper functions
  module Lib
    # Returns chef executable name to be used for looking in process list
    def chef_client_binary
      if Chefctl::Config.chef_client.is_a?(Array)
        return Chefctl::Config.chef_client[0]
      else
        return Chefctl::Config.chef_client
      end
    end

    # waits for currently running chef processes to exit.
    # If there are running chefctl processes but no chef-client processes, the
    # chefctl processes are killed so this process can run.
    # This is primarily for use with interactive tools (-i/--immediate)
    def stop_or_wait_for_chef(logfile = false)
      # check to see whether or not chef is running
      return if chefctl_procs.empty?
      return if logfile && !File.exist?(logfile)

      client_name = File.basename(chef_client_binary)

      # each chefctl instance can show up as 1-3 processes. so best case, we'll
      #
      # only queue 5 runs. worse case we'll queue 15 runs.
      if chefctl_procs.length < 15

        STDOUT.sync = true

        unless chefclient_procs.empty?
          # chef-client is currently running. we don't want to just kill it,
          # instead we want to wait for it to finish

          STDOUT << "Waiting for #{client_name} runs to complete "
          until chefclient_procs.empty?
            STDOUT << '.'
            sleep 5
          end
          STDOUT << "\nChef-client runs completed.\n"
        end

        STDOUT.sync = false

        # no more chef-client processes running. kill any chefctls left over
        procs = chefctl_procs
        unless procs.empty?
          Chefctl.logger.debug('Killing other chefctl processes: ' +
                               procs.join(' '))
          kill_processes(procs)
        end
      else
        quit 'Several chef runs already queued. Not queueing any more.', 0
      end
    end

    # Returns a standard formatted timestamp of the current time.
    def get_timestamp
      Time.now.strftime('%Y%m%d.%H%M.%s')
    end

    # Sets the mtime of a file.
    def set_mtime(file, new_time)
      stat = File.stat(file)
      File.utime(stat.atime, new_time, file)
    end

    # Checks that the current user is authorized to run chef.
    # I.e. they are root.
    def check_user
      proc_name = File.basename($PROGRAM_NAME)
      quit "You must be root to run #{proc_name}" unless Process.euid.zero?
    end

    # Loads the config file using the provided cli_options as overrides
    # to the defaults.
    def load_config(config_file, cli_options = {})
      validate_options(cli_options)
      filename = File.expand_path(config_file)
      if File.exist?(filename)
        Chefctl::Config.from_file(filename)
      end
      Chefctl::Config.merge!(cli_options)
    end

    def validate_options(options)
      if options[:splay] && options[:immediate]
        Chefctl.logger.error('Splay and immediate options are mutually ' +
                             'exclusive. You passed both. Try again.')
        exit 1
      end
    end

    # Linux platform-dependent helpers
    class Linux
      include Chefctl::Lib

      # Runs the provided command as a shell command. Returns the stdout of
      # the command. Raises an exception if the command fails.
      def shell_output(command)
        ps = Mixlib::ShellOut.new(command)
        yield(ps) if block_given?
        ps.run_command.error!
        ps.stdout
      end

      # Returns a list of processes whos commands match the given command.
      # `command` should be a Regexp or String.
      # `exclude` should be an Array of Regexp.
      # If exclude is provided, commands that match any of the entries are
      # not included in the output.
      # Returns an Array of Hashes with keys: `:pid`, `:command`, `:nsid`
      def list_processes(command, exclude = nil, parents = false,
                         same_nsid = true)
        check_nsid = same_nsid
        exclude ||= []
        procs = []

        # `ps` on older platforms (notably centos6 and osx) don't have a pidns
        # output field, so this command will fail there.
        # If that's the case, then fall back to the old behavior, and disable
        # the namespace id checking.
        begin
          out = shell_output('ps -e -o pid,pidns,command 2>/dev/null')
          out.lines.each do |l|
            fields = l.split
            procs << {
              :pid => fields[0].to_i,
              :nsid => fields[1],
              :command => fields[2..fields.length].join(' '),
            }
          end
        rescue Mixlib::ShellOut::ShellCommandFailed
          check_nsid = false
          out = shell_output('ps -e -o pid,command')
          out.lines.each do |l|
            fields = l.split
            procs << {
              :pid => fields[0].to_i,
              :nsid => nil,
              :command => fields[1..fields.length].join(' '),
            }
          end
        end

        # only want processes that match the command
        case command
        when String
          procs.select! { |p| p[:command].include?(command) }
        when Regexp
          procs.select! { |p| command =~ p[:command] }
        end

        # don't want stuff to exclude
        exclude.each do |b|
          procs.reject! { |p| b =~ p[:command] }
        end

        # don't include stuff above us in the process tree
        unless parents
          ppids = parent_group(Process.pid)
          procs.reject! { |p| ppids.include?(p[:pid]) }
        end

        # Don't return processes in different namespace IDs
        # We do this so chefctl runs on hosts don't see chefctl runs in that
        # host's containers.
        nsid_f = "/proc/#{Process.pid}/ns/pid"
        if File.exist?(nsid_f) && check_nsid
          pid_ns = File.readlink(nsid_f)
          r = /pid:\[(\d*)\]/.match(pid_ns)
          if r
            procs.select! do |p|
              x = p[:nsid] == '-' || r[1] == p[:nsid]
              unless x
                Chefctl.logger.debug(
                  "Ignoring (#{p[:pid]},#{p[:command].inspect}) since it's " +
                  "in a different namespace #{p[:nsid]}",
                )
              end
              x
            end
          else
            Chefctl.logger.error(
              "Uh oh. I couldn't figure out my own pid nsid: #{pid_ns.inspect}",
            )
          end
        else
          Chefctl.logger.debug('Not checking for process namespaces.')
        end

        return procs
      end

      # returns an array of pids of running chefctl processes
      def chefctl_procs
        chef_procs = list_processes(
          /#{Chefctl.program_name}/,
          [
            # if someone is editing/viewing chefctl on the box,
            # don't kill their editor.
            /vi/,
            /less/,
            /more/,
            /emacs/,
            # Don't kill any ssh processes, but we might kill their children
            # separately. It'll get cleaned up if the child gets killed anyway.
            /ssh/,
            # Don't kill antlir image builds
            /antlir/,
            # Facebook-ism, ignore
            /sush/,
            /h[\ds]h/,
          ],
        )

        # return only the pids
        chef_procs.map do |p|
          p[:pid]
        end
      end

      # returns an array of pids of running chef-client processes
      def chefclient_procs
        # chef_client may be a full path
        client_name = File.basename(chef_client_binary)
        client_procs = list_processes(client_name)
        client_procs.map do |p|
          p[:pid]
        end
      end

      # Sends sigterm to the list of processes identifiers provided.
      def kill_processes(procs)
        Process.kill('SIGTERM', *procs)
      end

      # Reads from the provided file, non-blocking
      def read_nonblock(f)
        f.read_nonblock(Chefctl::BUFFER_SIZE)
      end

      def symlink(old_name, new_name)
        FileUtils.ln_s(old_name, new_name, :force => true)
      end

      private

      # returns the parent of a given process
      def parent_process(pid)
        out = shell_output("ps -o ppid -p #{pid}")
        ppid = -1
        out.each_line do |l|
          next if /PPID/ =~ l
          ppid = l.strip.to_i
        end
        fail "Couldn't determine ppid of #{pid}" if ppid == -1
        ppid
      end

      # returns an array of pids that are in the parent tree of the provided
      # process (including itself)
      # e.g. if A forks B, B forks C, and B forks D:
      # parent_group(C) => [A, B, C]
      # parent_group(B) => [A, B]
      # parent_group(D) => [A, B, D]
      def parent_group(current)
        parents = []
        while current.nonzero?
          parents << current
          current = parent_process(current)
        end
        parents
      end
    end # class Linux

    class Windows
      include Chefctl::Lib

      class SubshellChefRun
        # This class' main purpose is to stand-in for a call to Mixlib::ShellOut
        # Some processes that chef spawns do not play very nicely when being
        # invoked via chefctl, such as a powershell_script resource.
        # It appears to be more reliable to have Chef invoked via the
        # Kernel.system call, which causes the resources to execute normally.
        attr_accessor :exitstatus

        def initialize(cmd)
          @exitstatus = system(cmd) ? 0 : 1
        end
      end

      def self.run_chef_via_subshell(cmd)
        SubshellChefRun.new(cmd)
      end

      # returns an array of pids of running chefctl processes
      def chefctl_procs
        require 'wmi-lite'
        this_pid = Process.pid
        wmi = WmiLite::Wmi.new
        proc_query = %{
          SELECT
            *
          FROM
            Win32_Process
          WHERE
            CommandLine LIKE "%chefctl%"
          AND
            Name LIKE "%ruby%"
          AND
            ProcessId <> #{this_pid}
        }

        wmi.query(proc_query).map { |p| p['processid'] }
      end

      # returns an array of pids of running chef-client processes
      def chefclient_procs
        require 'wmi-lite'
        this_pid = Process.pid
        wmi = WmiLite::Wmi.new
        proc_query = %{
          SELECT
            *
          FROM
            Win32_Process
          WHERE
            CommandLine LIKE "%chef-client%"
          AND
            Name LIKE "%ruby%"
          AND
            ProcessId <> #{this_pid}
        }

        wmi.query(proc_query).map { |p| p['processid'] }
      end

      # Sends sigterm to the list of processes identifiers provided.
      def kill_processes(procs)
        procs.each do |pid|
          Mixlib::ShellOut.new("TASKKILL /F /PID #{pid}").run_command
        end
        sleep(2) # Give time for lock to release
      end

      # Reads from the provided file, non-blocking
      def read_nonblock(logf)
        logf.sysread(Chefctl::BUFFER_SIZE)
      end

      def symlink(old_name, new_name)
        if File.exist?(new_name)
          File.unlink(new_name)
        end
        begin
          # Windows is fun since it has kinda clowny symlinks, we need to do
          # this foolishness to get a real symlink.
          require 'chef/win32/file'
          Chef::ReservedNames::Win32::File.symlink(old_name, new_name)
        rescue StandardError => e
          # If this fails for some reason we hope for the best
          Chefctl.logger.warn('Silently refusing to create a symlink ' +
                              "#{new_name} -> #{old_name}, #{e}")
          return false
        end
      end
    end # class Windows
  end # class Lib

  class Main
    attr_accessor :plugin

    def initialize(logdir, logfile)
      @plugin = Chefctl::Plugin.get_plugin

      @chef_name = File.basename(Chefctl.lib.chef_client_binary)
      @lock = {
        :file => Chefctl::Config.lock_file,
        :time => Chefctl::Config.lock_time,
        :fd => nil, # opened lock file
        :held => false,
      }
      @paths = {
        # Directories
        :logdir => logdir,

        # Output files
        :chef_cur => File.join(logdir, 'chef.cur.out'),
        :chef_last => File.join(logdir, 'chef.last.out'),
        :out => logfile,
        :first => File.join(logdir, 'chef.first.out'),
      }
    end

    # waits the given timeout for the lock identified by @lock[:fd] to become
    # available. timeout values <0 mean that it should try the lock exactly
    # once and not wait.
    def wait_for_lock(timeout = -1)
      # open the lockfile
      # we leave it open if we can acquire the lock,
      # otherwise we close it before we exit this function
      endtime = Time.now + timeout
      open_for_writing = false
      loop do
        unless open_for_writing
          begin
            @lock[:fd] = File.open(@lock[:file], 'a+')
            open_for_writing = true
          rescue Errno::EACCES
            # Windows will throw EACCES if the lock file is currently open in
            # another process.
            open_for_writing = false
          end
        end

        if open_for_writing
          acquired = @lock[:fd].flock(File::LOCK_EX | File::LOCK_NB)
          return true if acquired
        end

        if Time.now >= endtime
          @lock[:fd].close if open_for_writing
          return false
        else
          sleep 2
        end
      end
    end

    # extend testing duration to 1 hour from now, if it's not longer than that
    # already
    def keep_testing
      stamp_file = Chefctl::Config.testing_timestamp
      return unless File.exist?(stamp_file)
      now = Time.now
      new_time = now + 3600
      if File.mtime(stamp_file) - now < 3600
        Chefctl.logger.info('taste-tester mode ends in < 1 hour, ' +
                            'extending back to 1 hour')
        Chefctl.lib.set_mtime(stamp_file, new_time)
      end
    end

    # Acquire the lock
    def acquire_lock
      if Chefctl::Config.immediate
        Chefctl.lib.stop_or_wait_for_chef(@paths[:chef_cur])
      end

      Chefctl.logger.debug("Trying lock #{@lock[:file]}")
      acquired = wait_for_lock(-1)
      unless acquired
        held = 'another process'
        unless Chefctl.lib.is_a?(Chefctl::Lib::Windows)
          begin
            File.open(@lock[:file], 'r') do |f|
              held = f.read.strip
            end
          rescue Errno::ENOENT
            Chefctl.logger.info('Possible lockfile race, re-running wait_for_lock')
          end
        end
        Chefctl.logger.info("#{@lock[:file]} is locked by #{held}, " +
                            "waiting up to #{@lock[:time]} seconds.")
        unless wait_for_lock(@lock[:time])
          quit "Unable to lock #{@lock[:file]}"
        end
      end
      Chefctl.logger.debug("Lock acquired: #{@lock[:file]}")

      # mark us as owning the lock file
      @lock[:fd].truncate(0)
      @lock[:fd].write(Process.pid.to_s)

      # flush the pid to disk
      @lock[:fd].flush

      @lock[:held] = true
    end

    # Release the lock, if it's being held.
    def release_lock
      if @lock[:fd]
        if @lock[:held]
          @lock[:fd].flock(File::LOCK_UN)
          Chefctl.logger.debug("Releasing lock: #{@lock[:file]}")
        end

        # Some platforms panic if you try to unlink a file with open file
        # handles. /me glares silently in the direction of Redmond...
        @lock[:fd].close

        if Gem.win_platform?
          File.delete(@lock[:file])
        elsif File.exist?(@lock[:file]) && @lock[:held]
          File.unlink(@lock[:file])
        end
        @lock[:fd] = nil
      end
    end

    # acquire the lock, and `yield` within it.
    def lock
      acquire_lock
      yield
    rescue StandardError => e
      Chefctl.logger.error("Failed inside lock: #{e.inspect}:" +
                           "\n    #{e.backtrace.join("\n    ")}")
      raise
    ensure
      release_lock
    end

    # Perform a chef run
    def chef_run
      retval = 0
      lock do
        keep_testing
        plugin.generate_certs

        symlink_output(:chef_cur)

        do_splay unless Chefctl::Config.immediate

        plugin.pre_run(@paths[:out])

        retval = do_chef_runs

        plugin.post_run(@paths[:out], retval)

        symlink_output(:chef_last)

        save_firstrun
      end

      if retval > 0
        if Chefctl::Config.immediate || !Chefctl::Config.quiet
          Chefctl.logger.info("#{@chef_name} failed with exit code #{retval}," +
                       ' check log output!')
        end
      end

      Chefctl.close_logger
      return (retval != 0 ? Chefctl::CHEFCLIENT_FAILURE : 0)
    end

    # Symlink the current chef output file to the
    # provided link (key into @paths)
    def symlink_output(link)
      return unless Chefctl::Config.symlink_output
      Chefctl.lib.symlink(@paths[:out], @paths[link])
    end

    # Splay for the configured amount.
    # Waits for a random number in [1, Chefctl::Config.splay] seconds then
    # returns.
    def do_splay
      return unless Chefctl::Config.splay > 0

      t = rand(Chefctl::Config.splay)
      Chefctl.logger.info("splay: sleeping for #{t} seconds.")
      begin
        Chefctl.log_file.fsync
      rescue NotImplementedError
        Chefctl.logger.warn('No fsync support, splay message was delayed')
      end

      # Ruby doesn't respond to SIGTERM inside a sleep call.
      # So we sleep in one second intervals. This way if something sends us
      # a SIGTERM we can respond within a second.
      endtime = Time.now + t
      loop do
        if Time.now >= endtime
          return
        else
          sleep 1
        end
      end
    end

    # Runs chef until either it's been run Chefctl::Config.max_retries+1 times,
    # or the rerun_chef? returns False.
    def do_chef_runs
      retval = 0
      num_tries = 0
      loop do
        retval = run(num_tries)
        num_tries += 1

        # break if we've already run chef the max number of times
        if num_tries > Chefctl::Config.max_retries
          Chefctl.logger.debug('Hit max retries. Not running chef again.')
          break
        end

        # break unless we need to rerun chef for some reason
        unless plugin.rerun_chef?(@paths[:out], retval)
          Chefctl.logger.debug('rerun_chef? was false. Not running chef again.')
          break
        end
        Chefctl.logger.warn('Chef failed. Attempting to re-run chef.')
      end
      return retval
    end

    # Returns the chef command and arguments, as a string.
    def get_chef_cmd
      # Chef arguments from config.
      chef_args = []

      # Special command-line arguments
      if Chefctl::Config.trace
        chef_args += %w{-l trace}
      elsif Chefctl::Config.debug
        chef_args += %w{-l debug}
      end

      if Chefctl::Config.human || Chefctl::Config.whyrun
        chef_args += %w{-l fatal -F doc}
      else
        # force using the logger instead of the formatter
        chef_args << '--force-logger'
      end
      chef_args << '--why-run' if Chefctl::Config.whyrun
      chef_args << '--no-color' unless Chefctl::Config.color

      chef_args += Chefctl::Config.chef_options

      # Join them all together
      if Chefctl::Config.chef_client.is_a?(Array)
        cmd = Chefctl::Config.chef_client + chef_args
      else
        cmd = [
          Chefctl::Config.chef_client,
        ] + chef_args
      end

      Chefctl.logger.info("Running chef-client: #{cmd.inspect}")

      cmd
    end

    # Returns the environment for the chef process, as a hash
    def get_chef_env
      # Clear out the environment
      env = ENV.select { |k, _v| Chefctl::Config.passthrough_env.include?(k) }

      env['HOSTNAME'] = plugin.hostname

      if Chefctl::Config.path && Chefctl::Config.path.is_a?(Array)
        env['PATH'] = Chefctl::Config.path.join(File::PATH_SEPARATOR)
      end

      env
    end

    # copy data from the pipe to stdout and the log file.
    # return false if we reach the end of the file.
    def copy_output(logf)
      begin
        data = Chefctl.lib.read_nonblock(logf)
        STDOUT << data
      rescue EOFError
        return false
      end
      return true
    end

    # Returns a thread that is used to copy output from the log file
    # to chefctl's (this processes's) stdout.
    # If we're running in quiet mode we don't need this at all, so returns nil
    # The thread runs indefinitely until it is sent a RuntimeError via the
    # `raise` method. When it receives a RuntimeError, it will flush any
    # remaining log data, and close the log file.
    def output_copier_thread
      return nil if Chefctl::Config.quiet

      # Thread to copy the output from the file to stdout
      output_t = Thread.new do
        logf = File.open(@paths[:out], 'r')

        # seek over any data already in the file
        logf.seek(0, IO::SEEK_END)

        begin
          # Loop until we're sent a RuntimeError from the main thread.
          loop do
            # If we hit the EOF here, it means we're caught up, but chef
            # might write more stuff later. Sleep for a bit and try again.
            sleep(0.1) unless copy_output(logf)
          end
        rescue RuntimeError => e
          # flush anything left in the file to STDOUT
          # When we hit EOF, that's the actual end, so we're done.
          while copy_output(logf)
          end

          Chefctl.logger.debug("Stopped output copier: #{e}")
        ensure
          logf.close
        end
      end

      # we want to give the thread some time to start running, so that the
      # first bit of output of the chef run isn't lost
      sleep(0.1)

      output_t
    end

    # Perform a chef run.
    def run(retry_count)
      if Chefctl.lib.is_a?(Chefctl::Lib::Windows) &&
         Chefctl::Config.windows_subshell
        # TODO(yottatsa): this code is deprecated.
        # Windows users should proceed with Process.spawn
        #
        # subshell run ends up on `system` call,
        # which is seem to be working, but doesn't do any logging
        # and environment variables like PATH.
        Chefctl.logger.warn("Deprecated: windows_subshell shouldn't be used")
        cmd = get_chef_cmd.join(' ')
        chef_client =
          Chefctl::Lib::Windows.run_chef_via_subshell(cmd.freeze)
      else
        Chefctl.flush_logger
        output_t = output_copier_thread

        unless Chefctl.log_file
          Chefctl.logger.warn(
            'chefctl log file is nil!' +
            "Redirecting chef-client's output to the shell!",
          )
        end
        chef_env = get_chef_env
        if retry_count > 0
          Chefctl.logger.warn("This is a rerun attempt, rerun number #{retry_count}")
          chef_env['RETRY_COUNT'] = retry_count.to_s
        end
        Chefctl.logger.info("Running chef-client with the following environment variables: #{chef_env.inspect}")
        chef_client_pid = Process.spawn(
          chef_env,
          *get_chef_cmd,
          # Chefctl.log_file is set at the bottom of this file by the
          # init_logger call which is always passed a file, but just
          # to be safe, we only attempt to log to the file if it's non-nil.
          # otherwise, just echo the output to the terminal.
          [:out, :err] => (Chefctl.log_file ? Chefctl.log_file.to_i : STDERR),
          :close_others => true,
          # Windows requires lot of environment variables to be set. We used
          # subshell before, which means we barely changing the behavior.
          :unsetenv_others => !Chefctl.lib.is_a?(Chefctl::Lib::Windows),
        )
        begin
          if !Chefctl::Config.priority.nil?
            Process.setpriority(Process::PRIO_PROCESS, chef_client_pid, Chefctl::Config.priority)
            Chefctl.logger.info("Set priority of chef-client to #{Chefctl::Config.priority}.")
          end
        rescue Exception => e # rubocop:disable Lint/RescueException
          Chefctl.logger.info("Failed to set priority of chef-client #{e.message}")
        end
        chef_client = Process.wait2(chef_client_pid)[1]

        # output_t is nil if we're running with -q/--quiet
        if output_t
          # The output thread is tailing the output of the log file.
          # We need to interrupt it to stop the tail. Otherwise calling join
          # would just wait indefinitely.
          output_t.raise('this is normal')
          output_t.join(3)
        end
      end

      return chef_client.exitstatus
    end

    # Saves the output from the very first chef run indefinitely so we have
    # information about how the machine was originally setup.
    def save_firstrun
      unless File.exist?(@paths[:first])
        # It's a first-run if the current log is the oldest in the directory.
        # This is a heuristic; in first run, there may be additional chef timer
        # runs queued up, which means we have initial logs from chefctl.rb. So
        # we check if our current log has the oldest timestamp in the filename.
        #
        # The glob here depends on our log date formatting above in
        # Chefctl::Lib.get_timestamp
        oldest_log = Dir.glob(File.join(@paths[:logdir], 'chef.2*')).min
        if @paths[:out] == oldest_log
          Chefctl.logger.debug("Copying first-run log to #{@paths[:first]}")
          # Copy, don't symlink so it's not deleted later as more chef runs
          # happen.
          FileUtils.cp(@paths[:out], @paths[:first])
        else
          Chefctl.logger.debug("No first-run log at #{@paths[:first]}, but " +
            "the current log (#{@paths[:out]}) isn't the oldest log " +
            "(#{oldest_log}), so we're not copying to #{@paths[:first]}.")
        end
      end
    end
  end # class Main
end # module Chefctl

class TwoPassParser < OptionParser
  attr_accessor :first_pass

  def initialize(*args)
    @first_pass = true
    super(*args)
  end

  def parse_both_passes(argv = nil)
    first_pass = argv || ARGV
    second_pass = Array.new(first_pass)
    begin
      parse(first_pass)
    rescue OptionParser::InvalidOption => e
      # Invalid arguments during the first pass are expected since
      # the hook hasn't had a chance to define options yet.
      Chefctl.logger.debug("Got an invalid argument #{e.args} during the " +
                           'first pass. Ignoring.')
      # OptionParser will raise an InvalidOption exception whenever it finds
      # an option it doesn't know. When we get this, we delete the offending
      # options, and retry parsing options.
      e.args.each do |a|
        if first_pass.include?(a)
          first_pass.delete(a)
        else
          quit "Couldn't parse #{a}."
        end
      end
      retry
    end
    @first_pass = false
    yield
    parse!(second_pass)
    second_pass
  end
end # class TwoPassParser

if $PROGRAM_NAME == __FILE__
  Chefctl.lib

  config_file = Chefctl::DEFAULT_CONFIG
  options = {}

  parse = TwoPassParser.new do |parser|
    parser.banner = "Usage: #{$PROGRAM_NAME} [options]"

    parser.separator ''
    parser.separator 'Options:'

    # First-pass-only options:

    parser.on(
      '-C', '--config FILE',
      "Config file [default: #{Chefctl::DEFAULT_CONFIG}]"
    ) do |file|
      config_file = file if parser.first_pass
    end

    parser.on(
      '-p', '--plugin-path FILE',
      'Path to chefctl plugin file'
    ) do |v|
      # Only load the plugin path in the first pass, since we parse it
      # before doing the second pass.
      options[:plugin_path] = v if parser.first_pass
    end

    # Second-pass-only options:
    parser.on('-h', '--help') do
      # Only provide help in the second pass after we've let the hook define
      # additional command-line arguments.
      unless parser.first_pass
        puts parser
        exit 0
      end
    end

    # Pass-agnostic options:

    parser.on('-v', '--verbose', 'Verbose output from chefctl') do
      options[:verbose] = true
    end

    parser.on('-c', '--color', 'Enable colors') do
      options[:color] = true
    end

    parser.on(
      '-d', '--debug',
      'Enable chef debugging. This is a shortcut to passing " -- -l debug"' +
      ' directly'
    ) do
      Chefctl.logger.level = :debug
      options[:debug] = true
    end

    parser.on(
      '-H', '--human', 'Use "report handlers" aka. human readable output'
    ) do
      options[:human] = true
    end

    parser.on('-n', '--why-run', 'Enable why-run mode (like dry-run)') do
      options[:whyrun] = true
    end

    parser.on(
      '-i', '--immediate',
      'Execute immediately. No splay. Safely stop other chefctl processes' +
      ' that are queued. Mutually exclusive with -s option.'
    ) do
      options[:immediate] = true
    end

    parser.on(
      '-l', '--lock-timeout TIME',
      "Lock timeout. [default: #{Chefctl::Config.lock_time}]"
    ) do |v|
      options[:lock_time] = v.to_i
    end

    parser.on(
      '-L', '--lock-file FILE',
      "Lock file [default: #{Chefctl::Config.lock_file}]"
    ) do |v|
      options[:lock_file] = v
    end

    parser.on('-q', '--quiet', 'Do not print output to terminal') do
      options[:quiet] = true
    end

    parser.on(
      '-s', '--splay SECONDS',
      'Set the maximum number of seconds for random splay. Mutually exclusive' +
      " with the -i option. [default: #{Chefctl::Config.splay}]"
    ) do |v|
      options[:splay] = v.to_i
    end

    parser.on(
      '-t', '--trace',
      'Enable chef trace logging. This is a shortcut to passing' +
      '" -- -l trace" directly. This supersedes --debug'
    ) do
      Chefctl.logger.level = :trace
      options[:trace] = true
    end

    parser.on(
      '-w', '--wait-for-chef',
      'Simply run the stop-or-wait-for-chef step, do not actually run Chef'
    ) do
      options[:wait_for_chef] = true
    end

    parser.on(
      '--program PROGRAM',
      "name of the chefctl process. defaults to '#{$PROGRAM_NAME}'",
    ) do |v|
      Chefctl.program_name = v
    end

    parser.
      on(
        '--priority PRIORITY',
        'Set the process priority for chef-client using niceness or one of ' +
          "'Idle', 'BelowNormal', 'Normal', 'AboveNormal', 'High', 'Realtime'",
      ) do |v|
        priority_map = if Chefctl.lib.is_a?(Chefctl::Lib::Windows)
                         {
                           # Windows priority values from https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.processpriorityclass?view=net-9.0
                           'Idle' => 64,
                           'BelowNormal' => 16384,
                           'Normal' => 32,
                           'AboveNormal' => 32768,
                           'High' => 128,
                           'Realtime' => 256,
                         }
                       else
                         {
                           # Unix priority values based on https://man7.org/linux/man-pages/man1/nice.1.html
                           'Idle' => 19,
                           'BelowNormal' => 10,
                           'Normal' => 0,
                           'AboveNormal' => -5,
                           'High' => -10,
                           'Realtime' => -20,
                         }
                       end

        if v =~ /^-?\d+$/
          options[:priority] = v.to_i
        elsif priority_map.key?(v)
          options[:priority] = priority_map[v]
        else
          fail(ArgumentError, "Invalid priority value '#{v}'. Please use an integer or one of the predefined strings.")
        end
      end
  end

  args = parse.parse_both_passes do
    Chefctl.lib.load_config(config_file, options)

    Chefctl.logger.level = :debug if Chefctl::Config.verbose
    Chefctl::Plugin.load_file(Chefctl::Config.plugin_path)
    Chefctl::Plugin.get_plugin.cli_options(parse)
  end

  Chefctl.lib.check_user

  logdir = Chefctl::Config.log_dir
  logfile = File.join(logdir, "chef.#{Chefctl.lib.get_timestamp}.out")

  if File.file?(logdir)
    quit "Log directory #{logdir} is a file."
  end
  FileUtils.mkdir_p(logdir, :mode => 0o775) unless
      File.exist?(logdir)
  FileUtils.touch(logfile)
  Chefctl.init_logger(logfile)
  Chefctl.logger.level = :debug if Chefctl::Config.verbose

  if options[:wait_for_chef]
    Chefctl.lib.stop_or_wait_for_chef
    exit
  end

  Chefctl::Plugin.get_plugin.pre_start

  args.delete('--')
  Chefctl::Config.chef_options += args

  exit Chefctl::Main.new(logdir, logfile).chef_run
end

# rubocop:enable Style/CommentedKeyword
