chefctl/src/chefctl.rb (860 lines of code) (raw):

#!/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 # 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{ USER LOGNAME PWD HOME SUDO_USER XAR_MOUNT_SEED CHEF_LICENSE } # 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 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 = '/etc/chef/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/, # Facebook-ism, ignore /sush/, ], ) # 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) File.open(@lock[:file], 'r') do |f| held = f.read.strip 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 += 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.debug("Running: #{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 Chefctl.logger.debug("Using chef-client environment: #{env.inspect}") 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 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_client_pid = Process.spawn( get_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), ) 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 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