bolt 3.3.0 → 3.7.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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/Puppetfile +5 -5
  3. data/bolt-modules/boltlib/lib/puppet/datatypes/containerresult.rb +24 -0
  4. data/bolt-modules/boltlib/lib/puppet/functions/puppetdb_command.rb +66 -0
  5. data/bolt-modules/boltlib/lib/puppet/functions/run_command.rb +20 -2
  6. data/bolt-modules/boltlib/lib/puppet/functions/run_container.rb +162 -0
  7. data/bolt-modules/boltlib/lib/puppet/functions/run_script.rb +19 -2
  8. data/bolt-modules/boltlib/types/planresult.pp +1 -0
  9. data/bolt-modules/prompt/lib/puppet/functions/prompt.rb +20 -2
  10. data/bolt-modules/prompt/lib/puppet/functions/prompt/menu.rb +103 -0
  11. data/guides/targets.txt +31 -0
  12. data/lib/bolt/analytics.rb +4 -8
  13. data/lib/bolt/bolt_option_parser.rb +35 -17
  14. data/lib/bolt/cli.rb +109 -28
  15. data/lib/bolt/config.rb +11 -7
  16. data/lib/bolt/config/options.rb +41 -9
  17. data/lib/bolt/config/transport/lxd.rb +3 -1
  18. data/lib/bolt/config/transport/options.rb +7 -0
  19. data/lib/bolt/config/transport/podman.rb +33 -0
  20. data/lib/bolt/container_result.rb +105 -0
  21. data/lib/bolt/error.rb +15 -0
  22. data/lib/bolt/executor.rb +27 -15
  23. data/lib/bolt/inventory.rb +5 -4
  24. data/lib/bolt/inventory/inventory.rb +3 -2
  25. data/lib/bolt/inventory/options.rb +9 -0
  26. data/lib/bolt/inventory/target.rb +16 -0
  27. data/lib/bolt/node/output.rb +14 -4
  28. data/lib/bolt/outputter/human.rb +243 -84
  29. data/lib/bolt/outputter/json.rb +6 -4
  30. data/lib/bolt/outputter/logger.rb +17 -0
  31. data/lib/bolt/pal.rb +22 -2
  32. data/lib/bolt/pal/yaml_plan/step.rb +4 -2
  33. data/lib/bolt/pal/yaml_plan/step/command.rb +8 -0
  34. data/lib/bolt/pal/yaml_plan/step/script.rb +4 -0
  35. data/lib/bolt/pal/yaml_plan/transpiler.rb +2 -2
  36. data/lib/bolt/plan_creator.rb +2 -2
  37. data/lib/bolt/plugin.rb +13 -11
  38. data/lib/bolt/puppetdb/client.rb +54 -0
  39. data/lib/bolt/result.rb +5 -14
  40. data/lib/bolt/shell/bash.rb +33 -22
  41. data/lib/bolt/shell/powershell.rb +6 -8
  42. data/lib/bolt/transport/docker.rb +1 -1
  43. data/lib/bolt/transport/docker/connection.rb +21 -32
  44. data/lib/bolt/transport/lxd/connection.rb +5 -5
  45. data/lib/bolt/transport/orch.rb +13 -5
  46. data/lib/bolt/transport/podman.rb +19 -0
  47. data/lib/bolt/transport/podman/connection.rb +98 -0
  48. data/lib/bolt/util.rb +42 -0
  49. data/lib/bolt/version.rb +1 -1
  50. data/lib/bolt_server/transport_app.rb +3 -0
  51. data/lib/bolt_spec/plans/action_stubs/command_stub.rb +8 -1
  52. data/lib/bolt_spec/plans/action_stubs/script_stub.rb +8 -1
  53. data/lib/bolt_spec/plans/mock_executor.rb +91 -11
  54. data/modules/puppet_connect/plans/test_input_data.pp +22 -0
  55. metadata +11 -2
@@ -100,12 +100,12 @@ module Bolt
100
100
  moduledir: moduledir.to_s }.to_json)
101
101
  end
102
102
 
103
- def print_targets(target_list, inventoryfile)
103
+ def print_targets(target_list, inventory_source, default_inventory, _target_flag)
104
104
  @stream.puts ::JSON.pretty_generate(
105
105
  inventory: {
106
106
  targets: target_list[:inventory].map(&:name),
107
107
  count: target_list[:inventory].count,
108
- file: inventoryfile.to_s
108
+ file: (inventory_source || default_inventory).to_s
109
109
  },
110
110
  adhoc: {
111
111
  targets: target_list[:adhoc].map(&:name),
@@ -116,14 +116,16 @@ module Bolt
116
116
  )
117
117
  end
118
118
 
119
- def print_target_info(targets)
119
+ def print_target_info(target_list, _inventory_source, _default_inventory, _target_flag)
120
+ targets = target_list.values.flatten
121
+
120
122
  @stream.puts ::JSON.pretty_generate(
121
123
  targets: targets.map(&:detail),
122
124
  count: targets.count
123
125
  )
124
126
  end
125
127
 
126
- def print_groups(groups)
128
+ def print_groups(groups, _inventory_source, _default_inventory)
127
129
  count = groups.count
128
130
  @stream.puts({ groups: groups,
129
131
  count: count }.to_json)
@@ -20,6 +20,10 @@ module Bolt
20
20
  log_plan_start(event)
21
21
  when :plan_finish
22
22
  log_plan_finish(event)
23
+ when :container_start
24
+ log_container_start(event)
25
+ when :container_finish
26
+ log_container_finish(event)
23
27
  end
24
28
  end
25
29
 
@@ -48,6 +52,19 @@ module Bolt
48
52
  duration = event[:duration]
49
53
  @logger.info("Finished: plan #{plan} in #{duration.round(2)} sec")
50
54
  end
55
+
56
+ def log_container_start(event)
57
+ @logger.info("Starting: run container '#{event[:image]}'")
58
+ end
59
+
60
+ def log_container_finish(event)
61
+ result = event[:result]
62
+ if result.success?
63
+ @logger.info("Finished: run container '#{result.object}' succeeded.")
64
+ else
65
+ @logger.info("Finished: run container '#{result.object}' failed.")
66
+ end
67
+ end
51
68
  end
52
69
  end
53
70
  end
data/lib/bolt/pal.rb CHANGED
@@ -521,10 +521,30 @@ module Bolt
521
521
  end
522
522
  end
523
523
 
524
- def convert_plan(plan_path)
524
+ def convert_plan(plan)
525
+ path = File.expand_path(plan)
526
+
527
+ # If the path doesn't exist, check if it's a plan name
528
+ unless File.exist?(path)
529
+ in_bolt_compiler do |compiler|
530
+ sig = compiler.plan_signature(plan)
531
+
532
+ # If the plan was loaded, look for it on the module loader
533
+ # There has to be an easier way to do this...
534
+ if sig
535
+ type = compiler.list_plans.find { |p| p.name == plan }
536
+ path = sig.instance_variable_get(:@plan_func)
537
+ .loader
538
+ .find(type)
539
+ .origin
540
+ .first
541
+ end
542
+ end
543
+ end
544
+
525
545
  Puppet[:tasks] = true
526
546
  transpiler = YamlPlan::Transpiler.new
527
- transpiler.transpile(plan_path)
547
+ transpiler.transpile(path)
528
548
  end
529
549
 
530
550
  # Returns a mapping of all modules available to the Bolt compiler
@@ -122,9 +122,11 @@ module Bolt
122
122
  raise StepError.new("Parameters key must be a hash", body['name'], step_number)
123
123
  end
124
124
 
125
- metaparams = option_keys.map { |key| "_#{key}" }
125
+ metaparams = body['parameters'].keys
126
+ .select { |key| key.start_with?('_') }
127
+ .map { |key| key.sub(/^_/, '') }
126
128
 
127
- if (dups = body['parameters'].keys & metaparams).any?
129
+ if (dups = body.keys & metaparams).any?
128
130
  raise StepError.new(
129
131
  "Cannot specify metaparameters when using top-level keys with same name: #{dups.join(', ')}",
130
132
  body['name'],
@@ -13,6 +13,14 @@ module Bolt
13
13
  Set['command', 'targets']
14
14
  end
15
15
 
16
+ def self.validate_step_keys(body, number)
17
+ super
18
+
19
+ if body.key?('env_vars') && ![Hash, String].include?(body['env_vars'].class)
20
+ raise StepError.new('env_vars key must be a hash or evaluable string', body['name'], number)
21
+ end
22
+ end
23
+
16
24
  # Returns an array of arguments to pass to the step's function call
17
25
  #
18
26
  private def format_args(body)
@@ -27,6 +27,10 @@ module Bolt
27
27
  if body.key?('pwsh_params') && !body['pwsh_params'].nil? && !body['pwsh_params'].is_a?(Hash)
28
28
  raise StepError.new('pwsh_params key must be a hash', body['name'], number)
29
29
  end
30
+
31
+ if body.key?('env_vars') && ![Hash, String].include?(body['env_vars'].class)
32
+ raise StepError.new('env_vars key must be a hash or evaluable string', body['name'], number)
33
+ end
30
34
  end
31
35
 
32
36
  # Returns an array of arguments to pass to the step's function call
@@ -14,8 +14,8 @@ module Bolt
14
14
  end
15
15
  end
16
16
 
17
- def transpile(relative_path)
18
- @plan_path = File.expand_path(relative_path)
17
+ def transpile(plan_path)
18
+ @plan_path = plan_path
19
19
  @modulename = Bolt::Util.module_name(@plan_path)
20
20
  @filename = @plan_path.split(File::SEPARATOR)[-1]
21
21
  validate_path
@@ -36,8 +36,8 @@ module Bolt
36
36
  prefix, _, basename = segment_plan_name(plan_name)
37
37
 
38
38
  unless prefix == project.name
39
- message = "First segment of plan name '#{plan_name}' must match project name '#{project.name}'. "\
40
- "Did you mean '#{project.name}::#{plan_name}'?"
39
+ message = "Incomplete plan name: A plan name must be prefixed with the name of the "\
40
+ "project or module. Did you mean '#{project.name}::#{plan_name}'?"
41
41
 
42
42
  raise Bolt::ValidationError, message
43
43
  end
data/lib/bolt/plugin.rb CHANGED
@@ -178,8 +178,6 @@ module Bolt
178
178
  end
179
179
 
180
180
  def add_ruby_plugin(plugin_name)
181
- raise PluginError::LoadingDisabled, plugin_name unless @load_plugins
182
-
183
181
  cls_name = Bolt::Util.snake_name_to_class_name(plugin_name)
184
182
  filename = "bolt/plugin/#{plugin_name}"
185
183
  require filename
@@ -203,8 +201,6 @@ module Bolt
203
201
  }
204
202
 
205
203
  mod = modules[plugin_name]
206
- raise PluginError::Unknown, plugin_name unless mod&.plugin?
207
- raise PluginError::LoadingDisabled, plugin_name unless @load_plugins
208
204
 
209
205
  plugin = Bolt::Plugin::Module.load(mod, opts)
210
206
  add_plugin(plugin)
@@ -224,6 +220,12 @@ module Bolt
224
220
  end
225
221
  end
226
222
 
223
+ def known_plugin?(plugin_name)
224
+ @plugins.include?(plugin_name) ||
225
+ RUBY_PLUGINS.include?(plugin_name) ||
226
+ (modules.include?(plugin_name) && modules[plugin_name].plugin?)
227
+ end
228
+
227
229
  def get_hook(plugin_name, hook)
228
230
  plugin = by_name(plugin_name)
229
231
  raise PluginError::Unknown, plugin_name unless plugin
@@ -235,16 +237,16 @@ module Bolt
235
237
 
236
238
  # Calling by_name or get_hook will load any module based plugin automatically
237
239
  def by_name(plugin_name)
238
- return @plugins[plugin_name] if @plugins.include?(plugin_name)
239
- begin
240
- if RUBY_PLUGINS.include?(plugin_name)
240
+ if known_plugin?(plugin_name)
241
+ if @plugins.include?(plugin_name)
242
+ @plugins[plugin_name]
243
+ elsif !@load_plugins
244
+ raise PluginError::LoadingDisabled, plugin_name
245
+ elsif RUBY_PLUGINS.include?(plugin_name)
241
246
  add_ruby_plugin(plugin_name)
242
- elsif !@unknown.include?(plugin_name)
247
+ else
243
248
  add_module_plugin(plugin_name)
244
249
  end
245
- rescue PluginError::Unknown
246
- @unknown << plugin_name
247
- nil
248
250
  end
249
251
  end
250
252
 
@@ -95,6 +95,60 @@ module Bolt
95
95
  make_query(query, path)
96
96
  end
97
97
 
98
+ # Sends a command to PuppetDB using version 1 of the commands API.
99
+ # https://puppet.com/docs/puppetdb/latest/api/command/v1/commands.html
100
+ #
101
+ # @param command [String] The command to invoke.
102
+ # @param version [Integer] The version of the command to invoke.
103
+ # @param payload [Hash] The payload to send with the command.
104
+ # @return A UUID identifying the submitted command.
105
+ #
106
+ def send_command(command, version, payload)
107
+ command = command.dup.force_encoding('utf-8')
108
+ body = JSON.generate(payload)
109
+
110
+ # PDB requires the following query parameters to the POST request.
111
+ # Error early if there's no certname, as PDB does not return a
112
+ # message indicating it's required.
113
+ unless payload['certname']
114
+ raise Bolt::Error.new(
115
+ "Payload must include 'certname', unable to invoke command.",
116
+ 'bolt/pdb-command'
117
+ )
118
+ end
119
+
120
+ url = uri.tap do |u|
121
+ u.path = 'pdb/cmd/v1'
122
+ u.query_values = { 'command' => command,
123
+ 'version' => version,
124
+ 'certname' => payload['certname'] }
125
+ end
126
+
127
+ # Send the command to PDB
128
+ begin
129
+ @logger.debug("Sending PuppetDB command '#{command}' to #{url}")
130
+ response = http_client.post(url.to_s, body: body, header: headers)
131
+ rescue StandardError => e
132
+ raise Bolt::PuppetDBFailoverError, "Failed to invoke PuppetDB command: #{e}"
133
+ end
134
+
135
+ @logger.debug("Got response code #{response.code} from PuppetDB")
136
+ if response.code != 200
137
+ raise Bolt::PuppetDBError, "Failed to invoke PuppetDB command: #{response.body}"
138
+ end
139
+
140
+ # Return the UUID string from the response body
141
+ begin
142
+ JSON.parse(response.body).fetch('uuid', nil)
143
+ rescue JSON::ParserError
144
+ raise Bolt::PuppetDBError, "Unable to parse response as JSON: #{response.body}"
145
+ end
146
+ rescue Bolt::PuppetDBFailoverError => e
147
+ @logger.error("Request to puppetdb at #{@current_url} failed with #{e}.")
148
+ reject_url
149
+ send_command(command, version, payload)
150
+ end
151
+
98
152
  def http_client
99
153
  return @http if @http
100
154
  # lazy-load expensive gem code
data/lib/bolt/result.rb CHANGED
@@ -28,20 +28,14 @@ module Bolt
28
28
  %w[file line].zip(position).to_h.compact
29
29
  end
30
30
 
31
- def self.for_command(target, stdout, stderr, exit_code, action, command, position)
32
- value = {
33
- 'stdout' => stdout,
34
- 'stderr' => stderr,
35
- 'exit_code' => exit_code
36
- }
37
-
31
+ def self.for_command(target, value, action, command, position)
38
32
  details = create_details(position)
39
- unless exit_code == 0
40
- details['exit_code'] = exit_code
33
+ unless value['exit_code'] == 0
34
+ details['exit_code'] = value['exit_code']
41
35
  value['_error'] = {
42
36
  'kind' => 'puppetlabs.tasks/command-error',
43
37
  'issue_code' => 'COMMAND_ERROR',
44
- 'msg' => "The command failed with exit code #{exit_code}",
38
+ 'msg' => "The command failed with exit code #{value['exit_code']}",
45
39
  'details' => details
46
40
  }
47
41
  end
@@ -170,15 +164,12 @@ module Bolt
170
164
  target == other.target &&
171
165
  value == other.value
172
166
  end
167
+ alias == eql?
173
168
 
174
169
  def [](key)
175
170
  value[key]
176
171
  end
177
172
 
178
- def ==(other)
179
- eql?(other)
180
- end
181
-
182
173
  def to_json(opts = nil)
183
174
  to_data.to_json(opts)
184
175
  end
@@ -24,9 +24,7 @@ module Bolt
24
24
  running_as(options[:run_as]) do
25
25
  output = execute(command, environment: options[:env_vars], sudoable: true)
26
26
  Bolt::Result.for_command(target,
27
- output.stdout.string,
28
- output.stderr.string,
29
- output.exit_code,
27
+ output.to_h,
30
28
  'command',
31
29
  command,
32
30
  position)
@@ -98,9 +96,7 @@ module Bolt
98
96
  dir.chown(run_as)
99
97
  output = execute([path, *arguments], environment: options[:env_vars], sudoable: true)
100
98
  Bolt::Result.for_command(target,
101
- output.stdout.string,
102
- output.stderr.string,
103
- output.exit_code,
99
+ output.to_h,
104
100
  'script',
105
101
  script,
106
102
  position)
@@ -149,21 +145,21 @@ module Bolt
149
145
 
150
146
  remote_task_path = write_executable(task_dir, executable)
151
147
 
148
+ execute_options[:stdin] = stdin
149
+
152
150
  # Avoid the horrors of passing data on stdin via a tty on multiple platforms
153
151
  # by writing a wrapper script that directs stdin to the task.
154
152
  if stdin && target.options['tty']
155
153
  wrapper = make_wrapper_stringio(remote_task_path, stdin, execute_options[:interpreter])
154
+ # Wrapper script handles interpreter and stdin. Delete these execute options
156
155
  execute_options.delete(:interpreter)
156
+ execute_options.delete(:stdin)
157
157
  execute_options[:wrapper] = true
158
158
  remote_task_path = write_executable(dir, wrapper, 'wrapper.sh')
159
159
  end
160
160
 
161
161
  dir.chown(run_as)
162
162
 
163
- # Don't pass parameters on stdin if using a tty, as the parameters are
164
- # already part of the wrapper script.
165
- execute_options[:stdin] = stdin unless stdin && target.options['tty']
166
-
167
163
  execute_options[:sudoable] = true if run_as
168
164
  output = execute(remote_task_path, **execute_options)
169
165
  end
@@ -400,7 +396,12 @@ module Bolt
400
396
  # See if there's a sudo prompt
401
397
  if use_sudo
402
398
  ready_read = select([err], nil, nil, timeout * 5)
403
- read_streams[err] << check_sudo(err, inp, options[:stdin]) if ready_read
399
+ to_print = check_sudo(err, inp, options[:stdin]) if ready_read
400
+ unless to_print.nil?
401
+ log_stream(to_print, 'err')
402
+ read_streams[err] << to_print
403
+ result_output.merged_output << to_print
404
+ end
404
405
  end
405
406
 
406
407
  # True while the process is running or waiting for IO input
@@ -416,15 +417,9 @@ module Bolt
416
417
  else
417
418
  stream.readpartial(CHUNK_SIZE)
418
419
  end
419
-
420
- if !to_print.chomp.empty? && @stream_logger
421
- formatted = to_print.lines.map do |msg|
422
- "[#{@target.safe_name}] #{stream_name}: #{msg.chomp}"
423
- end.join("\n")
424
- @stream_logger.warn(formatted)
425
- end
426
-
427
- read_streams[stream] << to_print
420
+ log_stream(to_print, stream_name)
421
+ read_streams[stream] << to_print
422
+ result_output.merged_output << to_print
428
423
  rescue EOFError
429
424
  end
430
425
 
@@ -461,7 +456,13 @@ module Bolt
461
456
  # Read any remaining data in the pipe. Do not wait for
462
457
  # EOF in case the pipe is inherited by a child process.
463
458
  read_streams.each do |stream, _|
464
- loop { read_streams[stream] << stream.read_nonblock(CHUNK_SIZE) }
459
+ stream_name = stream == out ? 'out' : 'err'
460
+ loop {
461
+ to_print = stream.read_nonblock(CHUNK_SIZE)
462
+ log_stream(to_print, stream_name)
463
+ read_streams[stream] << to_print
464
+ result_output.merged_output << to_print
465
+ }
465
466
  rescue Errno::EAGAIN, EOFError
466
467
  end
467
468
  result_output.stdout << read_streams[out]
@@ -474,7 +475,8 @@ module Bolt
474
475
  when 126
475
476
  msg = "\n\nThis might be caused by the default tmpdir being mounted "\
476
477
  "using 'noexec'. See http://pup.pt/task-failure for details and workarounds."
477
- result_output.stderr << msg
478
+ result_output.stderr << msg
479
+ result_output.merged_output << msg
478
480
  @logger.trace { "Command #{command_str} failed with exit code #{result_output.exit_code}" }
479
481
  else
480
482
  @logger.trace { "Command #{command_str} failed with exit code #{result_output.exit_code}" }
@@ -491,6 +493,15 @@ module Bolt
491
493
  def sudo_prompt
492
494
  '[sudo] Bolt needs to run as another user, password: '
493
495
  end
496
+
497
+ private def log_stream(to_print, stream_name)
498
+ if !to_print.chomp.empty? && @stream_logger
499
+ formatted = to_print.lines.map do |msg|
500
+ "[#{@target.safe_name}] #{stream_name}: #{msg.chomp}"
501
+ end.join("\n")
502
+ @stream_logger.warn(formatted)
503
+ end
504
+ end
494
505
  end
495
506
  end
496
507
  end
@@ -195,9 +195,7 @@ module Bolt
195
195
  wrap_command = conn.is_a?(Bolt::Transport::Local::Connection)
196
196
  output = execute(command, wrap_command)
197
197
  Bolt::Result.for_command(target,
198
- output.stdout.string,
199
- output.stderr.string,
200
- output.exit_code,
198
+ output.to_h,
201
199
  'command',
202
200
  command,
203
201
  position)
@@ -224,9 +222,7 @@ module Bolt
224
222
  output = execute([shell_init, *env_assignments, command].join("\r\n"))
225
223
 
226
224
  Bolt::Result.for_command(target,
227
- output.stdout.string,
228
- output.stderr.string,
229
- output.exit_code,
225
+ output.to_h,
230
226
  'script',
231
227
  script,
232
228
  position)
@@ -322,7 +318,8 @@ module Bolt
322
318
  end.join("\n")
323
319
  @stream_logger.warn(formatted)
324
320
  end
325
- result.stdout << to_print
321
+ result.stdout << to_print
322
+ result.merged_output << to_print
326
323
  end
327
324
  stderr = Thread.new do
328
325
  encoding = err.external_encoding
@@ -334,7 +331,8 @@ module Bolt
334
331
  end.join("\n")
335
332
  @stream_logger.warn(formatted)
336
333
  end
337
- result.stderr << to_print
334
+ result.stderr << to_print
335
+ result.merged_output << to_print
338
336
  end
339
337
 
340
338
  stdout.join