bolt 2.19.0 → 2.24.0

Sign up to get free protection for your applications and to get access to all the features.

Potentially problematic release.


This version of bolt might be problematic. Click here for more details.

Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/Puppetfile +3 -1
  3. data/bolt-modules/boltlib/lib/puppet/functions/download_file.rb +123 -0
  4. data/bolt-modules/boltlib/lib/puppet/functions/run_plan.rb +6 -0
  5. data/bolt-modules/ctrl/lib/puppet/functions/ctrl/do_until.rb +12 -6
  6. data/bolt-modules/dir/lib/puppet/functions/dir/children.rb +35 -0
  7. data/bolt-modules/out/lib/puppet/functions/out/message.rb +1 -1
  8. data/exe/bolt +1 -0
  9. data/guides/inventory.txt +19 -0
  10. data/guides/project.txt +22 -0
  11. data/lib/bolt/analytics.rb +5 -5
  12. data/lib/bolt/applicator.rb +4 -3
  13. data/lib/bolt/bolt_option_parser.rb +100 -27
  14. data/lib/bolt/catalog.rb +12 -3
  15. data/lib/bolt/cli.rb +356 -156
  16. data/lib/bolt/config.rb +2 -2
  17. data/lib/bolt/config/options.rb +18 -4
  18. data/lib/bolt/executor.rb +30 -7
  19. data/lib/bolt/inventory/group.rb +6 -5
  20. data/lib/bolt/inventory/inventory.rb +4 -3
  21. data/lib/bolt/logger.rb +3 -4
  22. data/lib/bolt/module.rb +2 -1
  23. data/lib/bolt/outputter.rb +56 -0
  24. data/lib/bolt/outputter/human.rb +10 -9
  25. data/lib/bolt/outputter/json.rb +11 -4
  26. data/lib/bolt/outputter/logger.rb +2 -2
  27. data/lib/bolt/outputter/rainbow.rb +18 -2
  28. data/lib/bolt/pal.rb +13 -11
  29. data/lib/bolt/pal/yaml_plan/evaluator.rb +22 -1
  30. data/lib/bolt/pal/yaml_plan/step.rb +24 -2
  31. data/lib/bolt/pal/yaml_plan/step/download.rb +38 -0
  32. data/lib/bolt/pal/yaml_plan/step/message.rb +30 -0
  33. data/lib/bolt/pal/yaml_plan/step/upload.rb +3 -3
  34. data/lib/bolt/pal/yaml_plan/transpiler.rb +11 -3
  35. data/lib/bolt/plugin/prompt.rb +3 -3
  36. data/lib/bolt/plugin/puppetdb.rb +3 -2
  37. data/lib/bolt/project.rb +7 -4
  38. data/lib/bolt/project_migrate.rb +138 -0
  39. data/lib/bolt/puppetdb/client.rb +2 -0
  40. data/lib/bolt/puppetdb/config.rb +16 -0
  41. data/lib/bolt/result.rb +7 -0
  42. data/lib/bolt/shell/bash.rb +31 -11
  43. data/lib/bolt/shell/powershell.rb +10 -4
  44. data/lib/bolt/transport/base.rb +24 -0
  45. data/lib/bolt/transport/docker.rb +8 -0
  46. data/lib/bolt/transport/docker/connection.rb +28 -10
  47. data/lib/bolt/transport/local/connection.rb +15 -2
  48. data/lib/bolt/transport/orch.rb +15 -3
  49. data/lib/bolt/transport/simple.rb +6 -0
  50. data/lib/bolt/transport/ssh/connection.rb +13 -5
  51. data/lib/bolt/transport/ssh/exec_connection.rb +24 -3
  52. data/lib/bolt/transport/winrm/connection.rb +125 -15
  53. data/lib/bolt/util.rb +27 -12
  54. data/lib/bolt/util/puppet_log_level.rb +4 -3
  55. data/lib/bolt/version.rb +1 -1
  56. data/lib/bolt_server/base_config.rb +1 -1
  57. data/lib/bolt_server/pe/pal.rb +1 -1
  58. data/lib/bolt_server/transport_app.rb +79 -2
  59. data/lib/bolt_spec/bolt_context.rb +7 -2
  60. data/lib/bolt_spec/plans.rb +16 -3
  61. data/lib/bolt_spec/plans/action_stubs.rb +3 -2
  62. data/lib/bolt_spec/plans/action_stubs/download_stub.rb +66 -0
  63. data/lib/bolt_spec/plans/mock_executor.rb +14 -1
  64. data/lib/bolt_spec/run.rb +22 -0
  65. data/libexec/apply_catalog.rb +2 -2
  66. data/libexec/bolt_catalog +4 -3
  67. data/libexec/custom_facts.rb +1 -1
  68. data/libexec/query_resources.rb +1 -1
  69. data/modules/secure_env_vars/plans/init.pp +20 -0
  70. metadata +11 -2
@@ -76,7 +76,15 @@ module Bolt
76
76
  target = request['target']
77
77
  plan_vars = shadow_vars('plan', request['plan_vars'], target['facts'])
78
78
  target_vars = shadow_vars('target', target['variables'], target['facts'])
79
- topscope_vars = target_vars.merge(plan_vars)
79
+
80
+ # Merge plan vars with target vars, while maintaining the order of the plan
81
+ # vars. It's critical that the order of plan vars is not changed, as Puppet
82
+ # will deserialize the variables in the order they appear. Variables may
83
+ # contain local references to variables that appear earlier in a plan. If
84
+ # these variables are moved before the variable they reference, Puppet will
85
+ # be unable to deserialize the data and raise an error.
86
+ topscope_vars = target_vars.reject { |k, _v| plan_vars.key?(k) }.merge(plan_vars)
87
+
80
88
  env_conf = { modulepath: request['modulepath'],
81
89
  facts: target['facts'],
82
90
  variables: topscope_vars }
@@ -138,9 +146,10 @@ module Bolt
138
146
  # That means the apply body either a) consists of just a
139
147
  # NodeDefinition, b) consists of a BlockExpression which may
140
148
  # contain NodeDefinitions, or c) doesn't contain NodeDefinitions.
141
- definitions = if ast.is_a?(Puppet::Pops::Model::BlockExpression)
149
+ definitions = case ast
150
+ when Puppet::Pops::Model::BlockExpression
142
151
  ast.statements.select { |st| st.is_a?(Puppet::Pops::Model::NodeDefinition) }
143
- elsif ast.is_a?(Puppet::Pops::Model::NodeDefinition)
152
+ when Puppet::Pops::Model::NodeDefinition
144
153
  [ast]
145
154
  else
146
155
  []
@@ -20,6 +20,7 @@ require 'bolt/logger'
20
20
  require 'bolt/outputter'
21
21
  require 'bolt/puppetdb'
22
22
  require 'bolt/plugin'
23
+ require 'bolt/project_migrate'
23
24
  require 'bolt/pal'
24
25
  require 'bolt/target'
25
26
  require 'bolt/version'
@@ -31,14 +32,15 @@ module Bolt
31
32
  COMMANDS = { 'command' => %w[run],
32
33
  'script' => %w[run],
33
34
  'task' => %w[show run],
34
- 'plan' => %w[show run convert],
35
- 'file' => %w[upload],
35
+ 'plan' => %w[show run convert new],
36
+ 'file' => %w[download upload],
36
37
  'puppetfile' => %w[install show-modules generate-types],
37
38
  'secret' => %w[encrypt decrypt createkeys],
38
39
  'inventory' => %w[show],
39
40
  'group' => %w[show],
40
41
  'project' => %w[init migrate],
41
- 'apply' => %w[] }.freeze
42
+ 'apply' => %w[],
43
+ 'guide' => %w[] }.freeze
42
44
 
43
45
  attr_reader :config, :options
44
46
 
@@ -75,72 +77,100 @@ module Bolt
75
77
  end
76
78
  private :help?
77
79
 
80
+ # Wrapper method that is called by the Bolt executable. Parses the command and
81
+ # then loads the project and config. Once config is loaded, it completes the
82
+ # setup process by configuring Bolt and issuing warnings.
83
+ #
84
+ # This separation is needed since the Bolt::Outputter class that normally handles
85
+ # printing errors relies on config being loaded. All setup that happens before
86
+ # config is loaded will have errors printed directly to stdout, while all errors
87
+ # raised after config is loaded are handled by the outputter.
78
88
  def parse
79
- begin
80
- parser = BoltOptionParser.new(options)
81
- # This part aims to handle both `bolt <mode> --help` and `bolt help <mode>`.
82
- remaining = handle_parser_errors { parser.permute(@argv) } unless @argv.empty?
83
- if @argv.empty? || help?(remaining)
84
- # Update the parser for the subcommand (or lack thereof)
85
- parser.update
86
- puts parser.help
87
- raise Bolt::CLIExit
88
- end
89
+ parse_command
90
+ load_config
91
+ finalize_setup
92
+ end
89
93
 
90
- options[:object] = remaining.shift
94
+ # Parses the command and validates options. All errors that are raised here
95
+ # are not handled by the outputter, as it relies on config being loaded.
96
+ def parse_command
97
+ parser = BoltOptionParser.new(options)
98
+ # This part aims to handle both `bolt <mode> --help` and `bolt help <mode>`.
99
+ remaining = handle_parser_errors { parser.permute(@argv) } unless @argv.empty?
100
+ if @argv.empty? || help?(remaining)
101
+ # Update the parser for the subcommand (or lack thereof)
102
+ parser.update
103
+ puts parser.help
104
+ raise Bolt::CLIExit
105
+ end
91
106
 
92
- # Only parse task_options for task or plan
93
- if %w[task plan].include?(options[:subcommand])
94
- task_options, remaining = remaining.partition { |s| s =~ /.+=/ }
95
- if options[:task_options]
96
- unless task_options.empty?
97
- raise Bolt::CLIError,
98
- "Parameters must be specified through either the --params " \
99
- "option or param=value pairs, not both"
100
- end
101
- options[:params_parsed] = true
102
- elsif task_options.any?
103
- options[:params_parsed] = false
104
- options[:task_options] = Hash[task_options.map { |a| a.split('=', 2) }]
105
- else
106
- options[:params_parsed] = true
107
- options[:task_options] = {}
107
+ options[:object] = remaining.shift
108
+
109
+ # Only parse task_options for task or plan
110
+ if %w[task plan].include?(options[:subcommand])
111
+ task_options, remaining = remaining.partition { |s| s =~ /.+=/ }
112
+ if options[:task_options]
113
+ unless task_options.empty?
114
+ raise Bolt::CLIError,
115
+ "Parameters must be specified through either the --params " \
116
+ "option or param=value pairs, not both"
108
117
  end
118
+ options[:params_parsed] = true
119
+ elsif task_options.any?
120
+ options[:params_parsed] = false
121
+ options[:task_options] = Hash[task_options.map { |a| a.split('=', 2) }]
122
+ else
123
+ options[:params_parsed] = true
124
+ options[:task_options] = {}
109
125
  end
110
- options[:leftovers] = remaining
111
-
112
- validate(options)
113
-
114
- @config = if ENV['BOLT_PROJECT']
115
- project = Bolt::Project.create_project(ENV['BOLT_PROJECT'], 'environment')
116
- Bolt::Config.from_project(project, options)
117
- elsif options[:configfile]
118
- Bolt::Config.from_file(options[:configfile], options)
119
- else
120
- project = if options[:boltdir]
121
- dir = Pathname.new(options[:boltdir])
122
- if (dir + Bolt::Project::BOLTDIR_NAME).directory?
123
- Bolt::Project.create_project(dir + Bolt::Project::BOLTDIR_NAME)
124
- else
125
- Bolt::Project.create_project(dir)
126
- end
126
+ end
127
+ options[:leftovers] = remaining
128
+
129
+ # Default to verbose for everything except plans
130
+ unless options.key?(:verbose)
131
+ options[:verbose] = options[:subcommand] != 'plan'
132
+ end
133
+
134
+ validate(options)
135
+
136
+ # Deprecation warnings can't be issued until after config is loaded, so
137
+ # store them for later.
138
+ @parser_deprecations = parser.deprecations
139
+ rescue Bolt::Error => e
140
+ fatal_error(e)
141
+ raise e
142
+ end
143
+
144
+ # Loads the project and configuration. All errors that are raised here are not
145
+ # handled by the outputter, as it relies on config being loaded.
146
+ def load_config
147
+ @config = if ENV['BOLT_PROJECT']
148
+ project = Bolt::Project.create_project(ENV['BOLT_PROJECT'], 'environment')
149
+ Bolt::Config.from_project(project, options)
150
+ elsif options[:configfile]
151
+ Bolt::Config.from_file(options[:configfile], options)
152
+ else
153
+ project = if options[:boltdir]
154
+ dir = Pathname.new(options[:boltdir])
155
+ if (dir + Bolt::Project::BOLTDIR_NAME).directory?
156
+ Bolt::Project.create_project(dir + Bolt::Project::BOLTDIR_NAME)
127
157
  else
128
- Bolt::Project.find_boltdir(Dir.pwd)
158
+ Bolt::Project.create_project(dir)
129
159
  end
130
- Bolt::Config.from_project(project, options)
131
- end
160
+ else
161
+ Bolt::Project.find_boltdir(Dir.pwd)
162
+ end
163
+ Bolt::Config.from_project(project, options)
164
+ end
165
+ rescue Bolt::Error => e
166
+ fatal_error(e)
167
+ raise e
168
+ end
132
169
 
133
- Bolt::Logger.configure(config.log, config.color)
134
- Bolt::Logger.analytics = analytics
135
- rescue Bolt::Error => e
136
- if $stdout.isatty
137
- # Print the error message in red, mimicking outputter.fatal_error
138
- $stdout.puts("\033[31m#{e.message}\033[0m")
139
- else
140
- $stdout.puts(e.message)
141
- end
142
- raise e
143
- end
170
+ # Completes the setup process by configuring Bolt and issuing warnings
171
+ def finalize_setup
172
+ Bolt::Logger.configure(config.log, config.color)
173
+ Bolt::Logger.analytics = analytics
144
174
 
145
175
  # Logger must be configured before checking path case and project file, otherwise warnings will not display
146
176
  config.check_path_case('modulepath', config.modulepath)
@@ -151,28 +181,11 @@ module Bolt
151
181
 
152
182
  # Display warnings created during parser and config initialization
153
183
  config.warnings.each { |warning| @logger.warn(warning[:msg]) }
154
- parser.deprecations.each { |dep| Bolt::Logger.deprecation_warning(dep[:type], dep[:msg]) }
184
+ @parser_deprecations.each { |dep| Bolt::Logger.deprecation_warning(dep[:type], dep[:msg]) }
155
185
  config.deprecations.each { |dep| Bolt::Logger.deprecation_warning(dep[:type], dep[:msg]) }
156
186
 
157
- # After validation, initialize inventory and targets. Errors here are better to catch early.
158
- # After this step
159
- # options[:target_args] will contain a string/array version of the targetting options this is passed to plans
160
- # options[:targets] will contain a resolved set of Target objects
161
- unless options[:subcommand] == 'puppetfile' ||
162
- options[:subcommand] == 'secret' ||
163
- options[:subcommand] == 'project' ||
164
- options[:action] == 'show' ||
165
- options[:action] == 'convert'
166
-
167
- update_targets(options)
168
- end
169
-
170
- unless options.key?(:verbose)
171
- # Default to verbose for everything except plans
172
- options[:verbose] = options[:subcommand] != 'plan'
173
- end
174
-
175
187
  warn_inventory_overrides_cli(options)
188
+
176
189
  options
177
190
  rescue Bolt::Error => e
178
191
  outputter.fatal_error(e)
@@ -247,6 +260,13 @@ module Bolt
247
260
  "Option '--noop' may only be specified when running a task or applying manifest code"
248
261
  end
249
262
 
263
+ if options[:env_vars]
264
+ unless %w[command script].include?(options[:subcommand]) && options[:action] == 'run'
265
+ raise Bolt::CLIError,
266
+ "Option '--env-var' may only be specified when running a command or script"
267
+ end
268
+ end
269
+
250
270
  if options[:subcommand] == 'apply' && (options[:object] && options[:code])
251
271
  raise Bolt::CLIError, "--execute is unsupported when specifying a manifest file"
252
272
  end
@@ -265,6 +285,10 @@ module Bolt
265
285
  raise Bolt::CLIError, "Must specify a value to #{options[:action]}"
266
286
  end
267
287
 
288
+ if options[:subcommand] == 'plan' && options[:action] == 'new' && !options[:object]
289
+ raise Bolt::CLIError, "Must specify a plan name."
290
+ end
291
+
268
292
  if options.key?(:debug) && options.key?(:log)
269
293
  raise Bolt::CLIError, "Only one of '--debug' or '--log-level' may be specified"
270
294
  end
@@ -329,9 +353,12 @@ module Bolt
329
353
  exit!
330
354
  end
331
355
 
332
- if options[:action] == 'convert'
333
- convert_plan(options[:object])
334
- return 0
356
+ # Initialize inventory and targets. Errors here are better to catch early.
357
+ # options[:target_args] will contain a string/array version of the targetting options this is passed to plans
358
+ # options[:targets] will contain a resolved set of Target objects
359
+ unless %w[project puppetfile secret guide].include?(options[:subcommand]) ||
360
+ %w[convert new show].include?(options[:action])
361
+ update_targets(options)
335
362
  end
336
363
 
337
364
  screen = "#{options[:subcommand]}_#{options[:action]}"
@@ -357,32 +384,37 @@ module Bolt
357
384
 
358
385
  analytics.screen_view(screen, screen_view_fields)
359
386
 
360
- if options[:action] == 'show'
361
- if options[:subcommand] == 'task'
387
+ case options[:action]
388
+ when 'show'
389
+ case options[:subcommand]
390
+ when 'task'
362
391
  if options[:object]
363
392
  show_task(options[:object])
364
393
  else
365
394
  list_tasks
366
395
  end
367
- elsif options[:subcommand] == 'plan'
396
+ when 'plan'
368
397
  if options[:object]
369
398
  show_plan(options[:object])
370
399
  else
371
400
  list_plans
372
401
  end
373
- elsif options[:subcommand] == 'inventory'
402
+ when 'inventory'
374
403
  if options[:detail]
375
404
  show_targets
376
405
  else
377
406
  list_targets
378
407
  end
379
- elsif options[:subcommand] == 'group'
408
+ when 'group'
380
409
  list_groups
381
410
  end
382
411
  return 0
383
- elsif options[:action] == 'show-modules'
412
+ when 'show-modules'
384
413
  list_modules
385
414
  return 0
415
+ when 'convert'
416
+ pal.convert_plan(options[:object])
417
+ return 0
386
418
  end
387
419
 
388
420
  message = 'There may be processes left executing on some nodes.'
@@ -392,18 +424,33 @@ module Bolt
392
424
  end
393
425
 
394
426
  case options[:subcommand]
427
+ when 'guide'
428
+ code = if options[:object]
429
+ show_guide(options[:object])
430
+ else
431
+ list_topics
432
+ end
395
433
  when 'project'
396
- if options[:action] == 'init'
434
+ case options[:action]
435
+ when 'init'
397
436
  code = initialize_project
398
- elsif options[:action] == 'migrate'
399
- code = migrate_project
437
+ when 'migrate'
438
+ inv = config.inventoryfile
439
+ path = config.project.path
440
+ code = Bolt::ProjectMigrate.new(path, outputter, inv).migrate_project
400
441
  end
401
442
  when 'plan'
402
- code = run_plan(options[:object], options[:task_options], options[:target_args], options)
443
+ case options[:action]
444
+ when 'new'
445
+ code = new_plan(options[:object])
446
+ when 'run'
447
+ code = run_plan(options[:object], options[:task_options], options[:target_args], options)
448
+ end
403
449
  when 'puppetfile'
404
- if options[:action] == 'generate-types'
450
+ case options[:action]
451
+ when 'generate-types'
405
452
  code = generate_types
406
- elsif options[:action] == 'install'
453
+ when 'install'
407
454
  code = install_puppetfile(config.puppetfile_config, config.puppetfile, config.modulepath)
408
455
  end
409
456
  when 'secret'
@@ -424,6 +471,7 @@ module Bolt
424
471
  elapsed_time = Benchmark.realtime do
425
472
  executor_opts = {}
426
473
  executor_opts[:description] = options[:description] if options.key?(:description)
474
+ executor_opts[:env_vars] = options[:env_vars] if options.key?(:env_vars)
427
475
  executor.subscribe(outputter)
428
476
  executor.subscribe(log_outputter)
429
477
  results =
@@ -445,11 +493,22 @@ module Bolt
445
493
  src = options[:object]
446
494
  dest = options[:leftovers].first
447
495
 
496
+ if src.nil?
497
+ raise Bolt::CLIError, "A source path must be specified"
498
+ end
499
+
448
500
  if dest.nil?
449
501
  raise Bolt::CLIError, "A destination path must be specified"
450
502
  end
451
- validate_file('source file', src, true)
452
- executor.upload_file(targets, src, dest, executor_opts)
503
+
504
+ case options[:action]
505
+ when 'download'
506
+ dest = File.expand_path(dest, Dir.pwd)
507
+ executor.download_file(targets, src, dest, executor_opts)
508
+ when 'upload'
509
+ validate_file('source file', src, true)
510
+ executor.upload_file(targets, src, dest, executor_opts)
511
+ end
453
512
  end
454
513
  end
455
514
 
@@ -477,7 +536,7 @@ module Bolt
477
536
  tasks = pal.list_tasks
478
537
  tasks.select! { |task| task.first.include?(options[:filter]) } if options[:filter]
479
538
  tasks.select! { |task| config.project.tasks.include?(task.first) } unless config.project.tasks.nil?
480
- outputter.print_tasks(tasks, pal.list_modulepath)
539
+ outputter.print_tasks(tasks, pal.user_modulepath)
481
540
  end
482
541
 
483
542
  def show_plan(plan_name)
@@ -488,7 +547,7 @@ module Bolt
488
547
  plans = pal.list_plans
489
548
  plans.select! { |plan| plan.first.include?(options[:filter]) } if options[:filter]
490
549
  plans.select! { |plan| config.project.plans.include?(plan.first) } unless config.project.plans.nil?
491
- outputter.print_plans(plans, pal.list_modulepath)
550
+ outputter.print_plans(plans, pal.user_modulepath)
492
551
  end
493
552
 
494
553
  def list_targets
@@ -506,6 +565,118 @@ module Bolt
506
565
  outputter.print_groups(groups)
507
566
  end
508
567
 
568
+ def new_plan(plan_name)
569
+ @logger.warn("Command 'bolt plan new' is experimental and subject to changes.")
570
+
571
+ if config.project.name.nil?
572
+ raise Bolt::Error.new(
573
+ "Project directory '#{config.project.path}' is not a named project. Unable to create "\
574
+ "a project-level plan. To name a project, set the 'name' key in the 'bolt-project.yaml' "\
575
+ "configuration file.",
576
+ "bolt/unnamed-project-error"
577
+ )
578
+ end
579
+
580
+ if plan_name !~ Bolt::Module::CONTENT_NAME_REGEX
581
+ message = <<~MESSAGE.chomp
582
+ Invalid plan name '#{plan_name}'. Plan names are composed of one or more name segments
583
+ separated by double colons '::'.
584
+
585
+ Each name segment must begin with a lowercase letter, and may only include lowercase
586
+ letters, digits, and underscores.
587
+
588
+ Examples of valid plan names:
589
+ - #{config.project.name}
590
+ - #{config.project.name}::my_plan
591
+ MESSAGE
592
+
593
+ raise Bolt::ValidationError, message
594
+ end
595
+
596
+ prefix, *name_segments, basename = plan_name.split('::')
597
+
598
+ # If the plan name is just the project name, then create an 'init' plan.
599
+ # Otherwise, use the last name segment for the plan's filename.
600
+ basename ||= 'init'
601
+
602
+ unless prefix == config.project.name
603
+ message = "First segment of plan name '#{plan_name}' must match project name '#{config.project.name}'. "\
604
+ "Did you mean '#{config.project.name}::#{plan_name}'?"
605
+
606
+ raise Bolt::ValidationError, message
607
+ end
608
+
609
+ dir_path = config.project.plans_path.join(*name_segments)
610
+
611
+ %w[pp yaml].each do |ext|
612
+ next unless (path = config.project.plans_path + "#{basename}.#{ext}").exist?
613
+ raise Bolt::Error.new(
614
+ "A plan with the name '#{plan_name}' already exists at '#{path}', nothing to do.",
615
+ 'bolt/existing-plan-error'
616
+ )
617
+ end
618
+
619
+ begin
620
+ FileUtils.mkdir_p(dir_path)
621
+ rescue Errno::EEXIST => e
622
+ raise Bolt::Error.new(
623
+ "#{e.message}; unable to create plan directory '#{dir_path}'",
624
+ 'bolt/existing-file-error'
625
+ )
626
+ end
627
+
628
+ plan_path = dir_path + "#{basename}.yaml"
629
+
630
+ plan_template = <<~PLAN
631
+ # This is the structure of a simple plan. To learn more about writing
632
+ # YAML plans, see the documentation: http://pup.pt/bolt-yaml-plans
633
+
634
+ # The description sets the description of the plan that will appear
635
+ # in 'bolt plan show' output.
636
+ description: A plan created with bolt plan new
637
+
638
+ # The parameters key defines the parameters that can be passed to
639
+ # the plan.
640
+ parameters:
641
+ targets:
642
+ type: TargetSpec
643
+ description: A list of targets to run actions on
644
+ default: localhost
645
+
646
+ # The steps key defines the actions the plan will take in order.
647
+ steps:
648
+ - message: Hello from #{plan_name}
649
+ - name: command_step
650
+ command: whoami
651
+ targets: $targets
652
+
653
+ # The return key sets the return value of the plan.
654
+ return: $command_step
655
+ PLAN
656
+
657
+ begin
658
+ File.write(plan_path, plan_template)
659
+ rescue Errno::EACCES => e
660
+ raise Bolt::FileError.new(
661
+ "#{e.message}; unable to create plan",
662
+ plan_path
663
+ )
664
+ end
665
+
666
+ output = <<~OUTPUT
667
+ Created plan '#{plan_name}' at '#{plan_path}'
668
+
669
+ Show this plan with:
670
+ bolt plan show #{plan_name}
671
+ Run this plan with:
672
+ bolt plan run #{plan_name}
673
+ OUTPUT
674
+
675
+ outputter.print_message(output)
676
+
677
+ 0
678
+ end
679
+
509
680
  def run_plan(plan_name, plan_arguments, nodes, options)
510
681
  unless nodes.empty?
511
682
  if plan_arguments['nodes'] || plan_arguments['targets']
@@ -609,8 +780,26 @@ module Bolt
609
780
  # Initializes a specified directory as a Bolt project and installs any modules
610
781
  # specified by the user, along with their dependencies
611
782
  def initialize_project
612
- project = Pathname.new(File.expand_path(options[:object] || Dir.pwd))
613
- config = project + 'bolt.yaml'
783
+ # Dir.pwd will return backslashes on Windows, but Pathname always uses
784
+ # forward slashes to concatenate paths. This results in paths like
785
+ # C:\User\Administrator/modules, which fail module install. This ensure
786
+ # forward slashes in the cwd path.
787
+ dir = File.expand_path(Dir.pwd)
788
+ name = options[:object] || File.basename(dir)
789
+ if name !~ Bolt::Module::MODULE_NAME_REGEX
790
+ if options[:object]
791
+ raise Bolt::ValidationError, "The provided project name '#{name}' is invalid; "\
792
+ "project name must begin with a lowercase letter and can include lowercase "\
793
+ "letters, numbers, and underscores."
794
+ else
795
+ raise Bolt::ValidationError, "The current directory name '#{name}' is an invalid "\
796
+ "project name. Please specify a name using 'bolt project init <name>'."
797
+ end
798
+ end
799
+
800
+ project = Pathname.new(dir)
801
+ old_config = project + 'bolt.yaml'
802
+ config = project + 'bolt-project.yaml'
614
803
  puppetfile = project + 'Puppetfile'
615
804
  modulepath = [project + 'modules']
616
805
 
@@ -631,18 +820,24 @@ module Bolt
631
820
 
632
821
  # Warn the user if the project directory already exists. We don't error here since users
633
822
  # might not have installed any modules yet.
823
+ # If both bolt.yaml and bolt-project.yaml exist, this will just warn
824
+ # about bolt-project.yaml and subsequent Bolt actions will warn about
825
+ # both files existing
634
826
  if config.exist?
635
- @logger.warn "Found existing project directory at #{project}"
636
- end
637
-
638
- # Create the project directory
639
- FileUtils.mkdir_p(project)
640
-
827
+ @logger.warn "Found existing project directory at #{project}. Skipping file creation."
828
+ # This won't get called if bolt-project.yaml exists
829
+ elsif old_config.exist?
830
+ @logger.warn "Found existing #{old_config.basename} at #{project}. "\
831
+ "#{old_config.basename} is deprecated, please rename to #{config.basename}."
641
832
  # Bless the project directory as a...wait for it...project
642
- if FileUtils.touch(config)
643
- outputter.print_message "Successfully created Bolt project at #{project}"
644
833
  else
645
- raise Bolt::FileError.new("Could not create Bolt project directory at #{project}", nil)
834
+ begin
835
+ content = { 'name' => name }
836
+ File.write(config.to_path, content.to_yaml)
837
+ outputter.print_message "Successfully created Bolt project at #{project}"
838
+ rescue StandardError => e
839
+ raise Bolt::FileError.new("Could not create bolt-project.yaml at #{project}: #{e.message}", nil)
840
+ end
646
841
  end
647
842
 
648
843
  # Write the generated Puppetfile to the fancy new project
@@ -718,49 +913,6 @@ module Bolt
718
913
  end
719
914
  end
720
915
 
721
- def migrate_project
722
- inventory_file = config.inventoryfile || config.default_inventoryfile
723
- data = Bolt::Util.read_yaml_hash(inventory_file, 'inventory')
724
-
725
- data.delete('version') if data['version'] != 2
726
-
727
- migrated = migrate_group(data)
728
-
729
- ok = File.write(inventory_file, data.to_yaml) if migrated
730
-
731
- result = if migrated && ok
732
- "Successfully migrated Bolt project to latest version."
733
- elsif !migrated
734
- "Bolt project already on latest version. Nothing to do."
735
- else
736
- "Could not migrate Bolt project to latest version."
737
- end
738
- outputter.print_message result
739
-
740
- ok ? 0 : 1
741
- end
742
-
743
- # Walks an inventory hash and replaces all 'nodes' keys with 'targets' keys
744
- # and all 'name' keys nested in a 'targets' hash with 'uri' keys. Data is
745
- # modified in place.
746
- def migrate_group(group)
747
- migrated = false
748
- if group.key?('nodes')
749
- migrated = true
750
- targets = group['nodes'].map do |target|
751
- target['uri'] = target.delete('name') if target.is_a?(Hash)
752
- target
753
- end
754
- group.delete('nodes')
755
- group['targets'] = targets
756
- end
757
- (group['groups'] || []).each do |subgroup|
758
- migrated_group = migrate_group(subgroup)
759
- migrated ||= migrated_group
760
- end
761
- migrated
762
- end
763
-
764
916
  def install_puppetfile(config, puppetfile, modulepath)
765
917
  require 'r10k/cli'
766
918
  require 'bolt/r10k_log_proxy'
@@ -803,8 +955,46 @@ module Bolt
803
955
  config.project)
804
956
  end
805
957
 
806
- def convert_plan(plan)
807
- pal.convert_plan(plan)
958
+ # Collects the list of Bolt guides and maps them to their topics.
959
+ def guides
960
+ @guides ||= begin
961
+ root_path = File.expand_path(File.join(__dir__, '..', '..', 'guides'))
962
+ files = Dir.children(root_path).sort
963
+
964
+ files.each_with_object({}) do |file, guides|
965
+ next if file !~ /\.txt\z/
966
+ topic = File.basename(file, '.txt')
967
+ guides[topic] = File.join(root_path, file)
968
+ end
969
+ rescue SystemCallError => e
970
+ raise Bolt::FileError.new("#{e.message}: unable to load guides directory", root_path)
971
+ end
972
+ end
973
+
974
+ # Display the list of available Bolt guides.
975
+ def list_topics
976
+ outputter.print_topics(guides.keys)
977
+ 0
978
+ end
979
+
980
+ # Display a specific Bolt guide.
981
+ def show_guide(topic)
982
+ if guides[topic]
983
+ analytics.event('Guide', 'known_topic', label: topic)
984
+
985
+ begin
986
+ guide = File.read(guides[topic])
987
+ rescue SystemCallError => e
988
+ raise Bolt::FileError("#{e.message}: unable to load guide page", filepath)
989
+ end
990
+
991
+ outputter.print_guide(guide, topic)
992
+ else
993
+ analytics.event('Guide', 'unknown_topic', label: topic)
994
+ outputter.print_message("Did not find guide for topic '#{topic}'.\n\n")
995
+ list_topics
996
+ end
997
+ 0
808
998
  end
809
999
 
810
1000
  def validate_file(type, path, allow_dir = false)
@@ -871,7 +1061,7 @@ module Bolt
871
1061
  msg = <<~MSG.chomp
872
1062
  Loaded configuration from: '#{config.config_files.join("', '")}'
873
1063
  MSG
874
- @logger.debug(msg)
1064
+ @logger.info(msg)
875
1065
  end
876
1066
 
877
1067
  # Gem installs include the aggregate, canary, and puppetdb_fact modules, while
@@ -879,5 +1069,15 @@ module Bolt
879
1069
  def incomplete_install?
880
1070
  (Dir.children(Bolt::PAL::MODULES_PATH) - %w[aggregate canary puppetdb_fact]).empty?
881
1071
  end
1072
+
1073
+ # Mimicks the output from Outputter::Human#fatal_error. This should be used to print
1074
+ # errors prior to config being loaded, as the outputter relies on config being loaded.
1075
+ def fatal_error(error)
1076
+ if $stdout.isatty
1077
+ $stdout.puts("\033[31m#{error.message}\033[0m")
1078
+ else
1079
+ $stdout.puts(error.message)
1080
+ end
1081
+ end
882
1082
  end
883
1083
  end