# frozen_string_literal: true require 'uri' require 'benchmark' require 'json' require 'io/console' require 'logging' require 'optparse' require 'bolt/analytics' require 'bolt/bolt_option_parser' require 'bolt/config' require 'bolt/error' require 'bolt/executor' require 'bolt/inventory' require 'bolt/logger' require 'bolt/outputter' require 'bolt/puppetdb' require 'bolt/pal' require 'bolt/target' require 'bolt/version' require 'bolt/util/on_access' module Bolt class CLIExit < StandardError; end class CLI COMMANDS = { 'command' => %w[run], 'script' => %w[run], 'task' => %w[show run], 'plan' => %w[show run], 'file' => %w[upload] }.freeze attr_reader :config, :options def initialize(argv) Bolt::Logger.initialize_logging @logger = Logging.logger[self] @config = Bolt::Config.new @argv = argv @options = { nodes: [] } end # Only call after @config has been initialized. def inventory @inventory ||= Bolt::Inventory.from_config(config) end private :inventory def help?(parser, remaining) # Set the mode options[:mode] = remaining.shift if options[:mode] == 'help' options[:help] = true options[:mode] = remaining.shift end # Update the parser for the new mode parser.update options[:help] end private :help? def parse parser = BoltOptionParser.new(options) # This part aims to handle both `bolt <mode> --help` and `bolt help <mode>`. remaining = handle_parser_errors { parser.permute(@argv) } unless @argv.empty? if @argv.empty? || help?(parser, remaining) puts parser.help raise Bolt::CLIExit end config.update(options) config.validate Bolt::Logger.configure(config) # This section handles parsing non-flag options which are # mode specific rather then part of the config options[:action] = remaining.shift options[:object] = remaining.shift task_options, remaining = remaining.partition { |s| s =~ /.+=/ } if options[:task_options] unless task_options.empty? raise Bolt::CLIError, "Parameters must be specified through either the --params " \ "option or param=value pairs, not both" end options[:params_parsed] = true else options[:params_parsed] = false options[:task_options] = Hash[task_options.map { |a| a.split('=', 2) }] end options[:leftovers] = remaining validate(options) # After validation, initialize inventory and targets. Errors here are better to catch early. unless options[:action] == 'show' if options[:query] if options[:nodes].any? raise Bolt::CLIError, "Only one of '--nodes' or '--query' may be specified" end nodes = query_puppetdb_nodes(options[:query]) options[:targets] = inventory.get_targets(nodes) options[:nodes] = nodes if options[:mode] == 'plan' else options[:targets] = inventory.get_targets(options[:nodes]) end end options rescue Bolt::Error => e warn e.message raise e end def validate(options) unless COMMANDS.include?(options[:mode]) raise Bolt::CLIError, "Expected subcommand '#{options[:mode]}' to be one of " \ "#{COMMANDS.keys.join(', ')}" end if options[:action].nil? raise Bolt::CLIError, "Expected an action of the form 'bolt #{options[:mode]} <action>'" end actions = COMMANDS[options[:mode]] unless actions.include?(options[:action]) raise Bolt::CLIError, "Expected action '#{options[:action]}' to be one of " \ "#{actions.join(', ')}" end if options[:mode] != 'file' && options[:mode] != 'script' && !options[:leftovers].empty? raise Bolt::CLIError, "Unknown argument(s) #{options[:leftovers].join(', ')}" end if %w[task plan].include?(options[:mode]) && options[:action] == 'run' if options[:object].nil? raise Bolt::CLIError, "Must specify a #{options[:mode]} to run" end # This may mean that we parsed a parameter as the object unless options[:object] =~ /\A([a-z][a-z0-9_]*)?(::[a-z][a-z0-9_]*)*\Z/ raise Bolt::CLIError, "Invalid #{options[:mode]} '#{options[:object]}'" end end if options[:mode] != 'plan' && options[:action] != 'show' if options[:nodes].empty? && options[:query].nil? raise Bolt::CLIError, "Targets must be specified with '--nodes' or '--query'" elsif options[:nodes].any? && options[:query] raise Bolt::CLIError, "Only one of '--nodes' or '--query' may be specified" end end if options[:noop] && (options[:mode] != 'task' || options[:action] != 'run') raise Bolt::CLIError, "Option '--noop' may only be specified when running a task" end end def handle_parser_errors yield rescue OptionParser::MissingArgument => e raise Bolt::CLIError, "Option '#{e.args.first}' needs a parameter" rescue OptionParser::InvalidArgument => e raise Bolt::CLIError, "Invalid parameter specified for option '#{e.args.first}': #{e.args[1]}" rescue OptionParser::InvalidOption, OptionParser::AmbiguousOption => e raise Bolt::CLIError, "Unknown argument '#{e.args.first}'" end def puppetdb_client return @puppetdb_client if @puppetdb_client @puppetdb_client = Bolt::Util::OnAccess.new do puppetdb_config = Bolt::PuppetDB::Config.new(nil, config.puppetdb) Bolt::PuppetDB::Client.from_config(puppetdb_config) end end def query_puppetdb_nodes(query) puppetdb_client.query_certnames(query) end def execute(options) message = nil handler = Signal.trap :INT do |signo| @logger.info( "Exiting after receiving SIG#{Signal.signame(signo)} signal.#{message ? ' ' + message : ''}" ) exit! end @analytics = Bolt::Analytics.build_client screen = "#{options[:mode]}_#{options[:action]}" # submit a different screen for `bolt task show` and `bolt task show foo` if options[:action] == 'show' && options[:object] screen += '_object' end @analytics.screen_view(screen, output_format: config[:format], target_nodes: options.fetch(:targets, []).count, inventory_nodes: inventory.node_names.count, inventory_groups: inventory.group_names.count) if options[:mode] == 'plan' || options[:mode] == 'task' pal = Bolt::PAL.new(config) end if options[:action] == 'show' if options[:mode] == 'task' if options[:object] outputter.print_task_info(pal.get_task_info(options[:object])) else outputter.print_table(pal.list_tasks) outputter.print_message("\nUse `bolt task show <task-name>` to view "\ "details and parameters for a specific task.") end elsif options[:mode] == 'plan' if options[:object] outputter.print_plan_info(pal.get_plan_info(options[:object])) else outputter.print_table(pal.list_plans) outputter.print_message("\nUse `bolt plan show <plan-name>` to view "\ "details and parameters for a specific plan.") end end return 0 end message = 'There may be processes left executing on some nodes.' if options[:task_options] && !options[:params_parsed] && pal options[:task_options] = pal.parse_params(options[:mode], options[:object], options[:task_options]) end if options[:mode] == 'plan' unless options[:nodes].empty? if options[:task_options]['nodes'] raise Bolt::CLIError, "A plan's 'nodes' parameter may be specified using the --nodes option, but in that " \ "case it must not be specified as a separate nodes=<value> parameter nor included " \ "in the JSON data passed in the --params option" end options[:task_options]['nodes'] = options[:nodes].join(',') end params = options[:noop] ? options[:task_options].merge("_noop" => true) : options[:task_options] plan_context = { plan_name: options[:object], params: params } plan_context[:description] = options[:description] if options[:description] executor = Bolt::Executor.new(config, @analytics, options[:noop]) executor.start_plan(plan_context) result = pal.run_plan(options[:object], options[:task_options], executor, inventory, puppetdb_client) # If a non-bolt exeception bubbles up the plan won't get finished executor.finish_plan(result) outputter.print_plan_result(result) code = result.ok? ? 0 : 1 else executor = Bolt::Executor.new(config, @analytics, options[:noop]) targets = options[:targets] results = nil outputter.print_head elapsed_time = Benchmark.realtime do executor_opts = {} executor_opts['_description'] = options[:description] if options.key?(:description) results = case options[:mode] when 'command' executor.run_command(targets, options[:object], executor_opts) do |event| outputter.print_event(event) end when 'script' script = options[:object] validate_file('script', script) executor.run_script( targets, script, options[:leftovers], executor_opts ) do |event| outputter.print_event(event) end when 'task' pal.run_task(options[:object], targets, options[:task_options], executor, inventory, options[:description]) do |event| outputter.print_event(event) end when 'file' src = options[:object] dest = options[:leftovers].first if dest.nil? raise Bolt::CLIError, "A destination path must be specified" end validate_file('source file', src) executor.file_upload(targets, src, dest, executor_opts) do |event| outputter.print_event(event) end end end outputter.print_summary(results, elapsed_time) code = results.ok ? 0 : 2 end code rescue Bolt::Error => e outputter.fatal_error(e) raise e ensure # restore original signal handler Signal.trap :INT, handler if handler @analytics&.finish end def validate_file(type, path) if path.nil? raise Bolt::CLIError, "A #{type} must be specified" end stat = file_stat(path) if !stat.readable? raise Bolt::FileError.new("The #{type} '#{path}' is unreadable", path) elsif !stat.file? raise Bolt::FileError.new("The #{type} '#{path}' is not a file", path) end rescue Errno::ENOENT raise Bolt::FileError.new("The #{type} '#{path}' does not exist", path) end def file_stat(path) File.stat(path) end def outputter @outputter ||= Bolt::Outputter.for_format(config[:format], config[:color], config[:trace]) end end end