# frozen_string_literal: true require 'bolt/pal' module Bolt class Outputter class Human < Bolt::Outputter COLORS = { red: "31", green: "32", yellow: "33" }.freeze def print_head; end def initialize(color, verbose, trace, stream = $stdout) super # Plans and without_default_logging() calls can both be nested, so we # track each of them with a "stack" consisting of an integer. @plan_depth = 0 @disable_depth = 0 end def colorize(color, string) if @color && @stream.isatty "\033[#{COLORS[color]}m#{string}\033[0m" else string end end def remove_trail(string) string.sub(/\s\z/, '') end def handle_event(event) case event[:type] when :enable_default_output @disable_depth -= 1 when :disable_default_output @disable_depth += 1 when :message print_message_event(event) end if enabled? case event[:type] when :node_start print_start(event[:target]) if @verbose when :node_result print_result(event[:result]) if @verbose when :step_start print_step_start(event) if plan_logging? when :step_finish print_step_finish(event) if plan_logging? when :plan_start print_plan_start(event) when :plan_finish print_plan_finish(event) end end end def enabled? @disable_depth == 0 end def plan_logging? @plan_depth > 0 end def print_start(target) @stream.puts(colorize(:green, "Started on #{target.safe_name}...")) end def print_result(result) if result.success? @stream.puts(colorize(:green, "Finished on #{result.target.safe_name}:")) else @stream.puts(colorize(:red, "Failed on #{result.target.safe_name}:")) end if result.error_hash @stream.puts(colorize(:red, remove_trail(indent(2, result.error_hash['msg'])))) end if result.is_a?(Bolt::ApplyResult) && @verbose result.resource_logs.each do |log| # Omit low-level info/debug messages next if %w[info debug].include?(log['level']) message = format_log(log) @stream.puts(indent(2, message)) end end # Only print results if there's something other than empty string and hash if result.value.empty? || (result.value.keys == ['_output'] && !result.message?) @stream.puts(indent(2, "#{result.action.capitalize} completed successfully with no result")) else # Only print messages that have something other than whitespace if result.message? @stream.puts(remove_trail(indent(2, result.message))) end # Use special handling if the result looks like a command or script result if result.generic_value.keys == %w[stdout stderr exit_code] safe_value = result.safe_value unless safe_value['stdout'].strip.empty? @stream.puts(indent(2, "STDOUT:")) @stream.puts(indent(4, safe_value['stdout'])) end unless safe_value['stderr'].strip.empty? @stream.puts(indent(2, "STDERR:")) @stream.puts(indent(4, safe_value['stderr'])) end elsif result.generic_value.any? @stream.puts(indent(2, ::JSON.pretty_generate(result.generic_value))) end end end def format_log(log) color = case log['level'] when 'warn' :yellow when 'err' :red end source = "#{log['source']}: " if log['source'] message = "#{log['level'].capitalize}: #{source}#{log['message']}" message = colorize(color, message) if color message end def print_step_start(description:, targets:, **_kwargs) target_str = if targets.length > 5 "#{targets.count} targets" else targets.map(&:safe_name).join(', ') end @stream.puts(colorize(:green, "Starting: #{description} on #{target_str}")) end def print_step_finish(description:, result:, duration:, **_kwargs) failures = result.error_set.length plural = failures == 1 ? '' : 's' message = "Finished: #{description} with #{failures} failure#{plural} in #{duration.round(2)} sec" @stream.puts(colorize(:green, message)) end def print_plan_start(event) @plan_depth += 1 # We use this event to both mark the start of a plan _and_ to enable # plan logging for `apply`, so only log the message if we were called # with a plan if event[:plan] @stream.puts(colorize(:green, "Starting: plan #{event[:plan]}")) end end def print_plan_finish(event) @plan_depth -= 1 plan = event[:plan] duration = event[:duration] @stream.puts(colorize(:green, "Finished: plan #{plan} in #{duration_to_string(duration)}")) end def print_summary(results, elapsed_time = nil) ok_set = results.ok_set unless ok_set.empty? @stream.puts format('Successful on %d target%s: %s', size: ok_set.size, plural: ok_set.size == 1 ? '' : 's', names: ok_set.targets.map(&:safe_name).join(',')) end error_set = results.error_set unless error_set.empty? @stream.puts colorize(:red, format('Failed on %d target%s: %s', size: error_set.size, plural: error_set.size == 1 ? '' : 's', names: error_set.targets.map(&:safe_name).join(','))) end total_msg = format('Ran on %d target%s', size: results.size, plural: results.size == 1 ? '' : 's') total_msg << " in #{duration_to_string(elapsed_time)}" unless elapsed_time.nil? @stream.puts total_msg end def print_table(results) # lazy-load expensive gem code require 'terminal-table' @stream.puts Terminal::Table.new( rows: results, style: { border_x: '', border_y: '', border_i: '', padding_left: 0, padding_right: 3, border_top: false, border_bottom: false } ) end def print_tasks(tasks, modulepath) print_table(tasks) print_message("\nMODULEPATH:\n#{modulepath.join(File::PATH_SEPARATOR)}\n"\ "\nUse `bolt task show ` to view "\ "details and parameters for a specific task.") end # @param [Hash] task A hash representing the task def print_task_info(task) # Building lots of strings... pretty_params = +"" task_info = +"" usage = +"bolt task run --targets #{task.name}" task.parameters&.each do |k, v| pretty_params << "- #{k}: #{v['type'] || 'Any'}\n" pretty_params << " Default: #{v['default'].inspect}\n" if v.key?('default') pretty_params << " #{v['description']}\n" if v['description'] usage << if v['type'].is_a?(Puppet::Pops::Types::POptionalType) " [#{k}=]" else " #{k}=" end end usage << " [--noop]" if task.supports_noop task_info << "\n#{task.name}" task_info << " - #{task.description}" if task.description task_info << "\n\n" task_info << "USAGE:\n#{usage}\n\n" task_info << "PARAMETERS:\n#{pretty_params}\n" unless pretty_params.empty? task_info << "MODULE:\n" path = task.files.first['path'].chomp("/tasks/#{task.files.first['name']}") task_info << if path.start_with?(Bolt::PAL::MODULES_PATH) "built-in module" else path end @stream.puts(task_info) end # @param [Hash] plan A hash representing the plan def print_plan_info(plan) # Building lots of strings... pretty_params = +"" plan_info = +"" usage = +"bolt plan run #{plan['name']}" plan['parameters'].each do |name, p| pretty_params << "- #{name}: #{p['type']}\n" pretty_params << " Default: #{p['default_value']}\n" unless p['default_value'].nil? pretty_params << " #{p['description']}\n" if p['description'] usage << (p.include?('default_value') ? " [#{name}=]" : " #{name}=") end plan_info << "\n#{plan['name']}" plan_info << " - #{plan['description']}" if plan['description'] plan_info << "\n\n" plan_info << "USAGE:\n#{usage}\n\n" plan_info << "PARAMETERS:\n#{pretty_params}\n" unless plan['parameters'].empty? plan_info << "MODULE:\n" path = plan['module'] plan_info << if path.start_with?(Bolt::PAL::MODULES_PATH) "built-in module" else path end @stream.puts(plan_info) end def print_plans(plans, modulepath) print_table(plans) print_message("\nMODULEPATH:\n#{modulepath.join(File::PATH_SEPARATOR)}\n"\ "\nUse `bolt plan show ` to view "\ "details and parameters for a specific plan.") end def print_topics(topics) print_message("Available topics are:") print_message(topics.join("\n")) print_message("\nUse `bolt guide ` to view a specific guide.") end def print_guide(guide, _topic) @stream.puts(guide) end def print_module_list(module_list) module_list.each do |path, modules| if (mod = modules.find { |m| m[:internal_module_group] }) @stream.puts(mod[:internal_module_group]) else @stream.puts(path) end if modules.empty? @stream.puts('(no modules installed)') else module_info = modules.map do |m| version = if m[:version].nil? m[:internal_module_group].nil? ? '(no metadata)' : '(built-in)' else m[:version] end [m[:name], version] end print_table(module_info) end @stream.write("\n") end end def print_targets(targets) count = "#{targets.count} target#{'s' unless targets.count == 1}" @stream.puts targets.map(&:name).join("\n") @stream.puts colorize(:green, count) end def print_target_info(targets) @stream.puts ::JSON.pretty_generate( "targets": targets.map(&:detail) ) count = "#{targets.count} target#{'s' unless targets.count == 1}" @stream.puts colorize(:green, count) end def print_groups(groups) count = "#{groups.count} group#{'s' unless groups.count == 1}" @stream.puts groups.join("\n") @stream.puts colorize(:green, count) end # @param [Bolt::ResultSet] apply_result A ResultSet object representing the result of a `bolt apply` def print_apply_result(apply_result, elapsed_time) print_summary(apply_result, elapsed_time) end # @param [Bolt::PlanResult] plan_result A PlanResult object def print_plan_result(plan_result) value = plan_result.value case value when nil @stream.puts("Plan completed successfully with no result") when Bolt::ApplyFailure, Bolt::RunFailure print_result_set(value.result_set) when Bolt::ResultSet print_result_set(value) else @stream.puts(::JSON.pretty_generate(plan_result, quirks_mode: true)) end end def print_result_set(result_set) result_set.each { |result| print_result(result) } print_summary(result_set) end def print_puppetfile_result(success, puppetfile, moduledir) if success @stream.puts("Successfully synced modules from #{puppetfile} to #{moduledir}") else @stream.puts(colorize(:red, "Failed to sync modules from #{puppetfile} to #{moduledir}")) end end def fatal_error(err) @stream.puts(colorize(:red, err.message)) if err.is_a? Bolt::RunFailure @stream.puts ::JSON.pretty_generate(err.result_set) end if @trace && err.backtrace err.backtrace.each do |line| @stream.puts(colorize(:red, "\t#{line}")) end end end def print_message(message) @stream.puts(message) end def duration_to_string(duration) hrs = (duration / 3600).floor mins = ((duration % 3600) / 60).floor secs = (duration % 60) if hrs > 0 "#{hrs} hr, #{mins} min, #{secs.round} sec" elsif mins > 0 "#{mins} min, #{secs.round} sec" else # Include 2 decimal places if the duration is under a minute "#{secs.round(2)} sec" end end end end end