bolt 3.1.0 → 3.3.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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/Puppetfile +8 -8
  3. data/bolt-modules/boltlib/lib/puppet/functions/add_facts.rb +1 -1
  4. data/bolt-modules/boltlib/lib/puppet/functions/run_plan.rb +2 -2
  5. data/bolt-modules/boltlib/lib/puppet/functions/run_script.rb +27 -5
  6. data/bolt-modules/boltlib/lib/puppet/functions/upload_file.rb +1 -1
  7. data/bolt-modules/file/lib/puppet/functions/file/read.rb +3 -2
  8. data/lib/bolt/apply_result.rb +1 -1
  9. data/lib/bolt/bolt_option_parser.rb +6 -3
  10. data/lib/bolt/cli.rb +37 -12
  11. data/lib/bolt/config.rb +4 -0
  12. data/lib/bolt/config/options.rb +21 -3
  13. data/lib/bolt/config/transport/lxd.rb +21 -0
  14. data/lib/bolt/config/transport/options.rb +1 -1
  15. data/lib/bolt/executor.rb +10 -3
  16. data/lib/bolt/logger.rb +8 -0
  17. data/lib/bolt/module_installer.rb +2 -2
  18. data/lib/bolt/module_installer/puppetfile.rb +2 -2
  19. data/lib/bolt/module_installer/specs/forge_spec.rb +2 -2
  20. data/lib/bolt/module_installer/specs/git_spec.rb +2 -2
  21. data/lib/bolt/outputter/human.rb +47 -12
  22. data/lib/bolt/pal.rb +2 -2
  23. data/lib/bolt/pal/yaml_plan.rb +1 -2
  24. data/lib/bolt/pal/yaml_plan/evaluator.rb +5 -141
  25. data/lib/bolt/pal/yaml_plan/step.rb +91 -31
  26. data/lib/bolt/pal/yaml_plan/step/command.rb +16 -16
  27. data/lib/bolt/pal/yaml_plan/step/download.rb +15 -16
  28. data/lib/bolt/pal/yaml_plan/step/eval.rb +11 -11
  29. data/lib/bolt/pal/yaml_plan/step/message.rb +13 -4
  30. data/lib/bolt/pal/yaml_plan/step/plan.rb +19 -15
  31. data/lib/bolt/pal/yaml_plan/step/resources.rb +82 -21
  32. data/lib/bolt/pal/yaml_plan/step/script.rb +32 -17
  33. data/lib/bolt/pal/yaml_plan/step/task.rb +19 -16
  34. data/lib/bolt/pal/yaml_plan/step/upload.rb +16 -17
  35. data/lib/bolt/pal/yaml_plan/transpiler.rb +1 -1
  36. data/lib/bolt/plan_creator.rb +1 -1
  37. data/lib/bolt/project_manager.rb +1 -1
  38. data/lib/bolt/project_manager/module_migrator.rb +1 -1
  39. data/lib/bolt/shell.rb +16 -0
  40. data/lib/bolt/shell/bash.rb +48 -21
  41. data/lib/bolt/shell/bash/tmpdir.rb +2 -2
  42. data/lib/bolt/shell/powershell.rb +24 -5
  43. data/lib/bolt/task.rb +1 -1
  44. data/lib/bolt/transport/lxd.rb +26 -0
  45. data/lib/bolt/transport/lxd/connection.rb +99 -0
  46. data/lib/bolt/transport/ssh/connection.rb +1 -1
  47. data/lib/bolt/transport/winrm/connection.rb +1 -1
  48. data/lib/bolt/version.rb +1 -1
  49. data/lib/bolt_server/transport_app.rb +13 -1
  50. data/lib/bolt_spec/plans/action_stubs.rb +1 -1
  51. data/lib/bolt_spec/plans/mock_executor.rb +4 -0
  52. metadata +5 -2
@@ -6,35 +6,50 @@ module Bolt
6
6
  class Step
7
7
  class Script < Step
8
8
  def self.allowed_keys
9
- super + Set['script', 'parameters', 'arguments']
9
+ super + Set['arguments', 'pwsh_params']
10
+ end
11
+
12
+ def self.option_keys
13
+ Set['catch_errors', 'env_vars', 'run_as']
10
14
  end
11
15
 
12
16
  def self.required_keys
13
- Set['targets']
17
+ Set['script', 'targets']
14
18
  end
15
19
 
16
- def initialize(step_body)
20
+ def self.validate_step_keys(body, number)
17
21
  super
18
- @script = step_body['script']
19
- @parameters = step_body.fetch('parameters', {})
20
- @arguments = step_body.fetch('arguments', [])
22
+
23
+ if body.key?('arguments') && !body['arguments'].nil? && !body['arguments'].is_a?(Array)
24
+ raise StepError.new('arguments key must be an array', body['name'], number)
25
+ end
26
+
27
+ if body.key?('pwsh_params') && !body['pwsh_params'].nil? && !body['pwsh_params'].is_a?(Hash)
28
+ raise StepError.new('pwsh_params key must be a hash', body['name'], number)
29
+ end
21
30
  end
22
31
 
23
- def transpile
24
- code = String.new(" ")
25
- code << "$#{@name} = " if @name
32
+ # Returns an array of arguments to pass to the step's function call
33
+ #
34
+ private def format_args(body)
35
+ args = body['arguments'] || []
36
+ pwsh_params = body['pwsh_params'] || {}
26
37
 
27
- options = @parameters.dup
28
- options['arguments'] = @arguments unless @arguments.empty?
38
+ opts = format_options(body)
39
+ opts = opts.merge('arguments' => args) if args.any?
40
+ opts = opts.merge('pwsh_params' => pwsh_params) if pwsh_params.any?
29
41
 
30
- fn = 'run_script'
31
- args = [@script, @targets]
32
- args << @description if @description
33
- args << options unless options.empty?
42
+ args = [body['script'], body['targets']]
43
+ args << body['description'] if body['description']
44
+ args << opts if opts.any?
34
45
 
35
- code << function_call(fn, args)
46
+ args
47
+ end
36
48
 
37
- code << "\n"
49
+ # Returns the function corresponding to the step
50
+ #
51
+ private def function
52
+ 'run_script'
38
53
  end
39
54
  end
40
55
  end
@@ -6,31 +6,34 @@ module Bolt
6
6
  class Step
7
7
  class Task < Step
8
8
  def self.allowed_keys
9
- super + Set['task', 'parameters']
9
+ super + Set['parameters']
10
10
  end
11
11
 
12
- def self.required_keys
13
- Set['targets']
12
+ def self.option_keys
13
+ Set['catch_errors', 'noop', 'run_as']
14
14
  end
15
15
 
16
- def initialize(step_body)
17
- super
18
- @task = step_body['task']
19
- @parameters = step_body.fetch('parameters', {})
16
+ def self.required_keys
17
+ Set['targets', 'task']
20
18
  end
21
19
 
22
- def transpile
23
- code = String.new(" ")
24
- code << "$#{@name} = " if @name
20
+ # Returns an array of arguments to pass to the step's function call
21
+ #
22
+ private def format_args(body)
23
+ opts = format_options(body)
24
+ params = (body['parameters'] || {}).merge(opts)
25
25
 
26
- fn = 'run_task'
27
- args = [@task, @targets]
28
- args << @description if @description
29
- args << @parameters unless @parameters.empty?
26
+ args = [body['task'], body['targets']]
27
+ args << body['description'] if body['description']
28
+ args << params if params.any?
30
29
 
31
- code << function_call(fn, args)
30
+ args
31
+ end
32
32
 
33
- code << "\n"
33
+ # Returns the function corresponding to the step
34
+ #
35
+ private def function
36
+ 'run_task'
34
37
  end
35
38
  end
36
39
  end
@@ -5,31 +5,30 @@ module Bolt
5
5
  class YamlPlan
6
6
  class Step
7
7
  class Upload < Step
8
- def self.allowed_keys
9
- super + Set['destination', 'upload']
8
+ def self.option_keys
9
+ Set['catch_errors', 'run_as']
10
10
  end
11
11
 
12
12
  def self.required_keys
13
- Set['upload', 'destination', 'targets']
13
+ Set['destination', 'targets', 'upload']
14
14
  end
15
15
 
16
- def initialize(step_body)
17
- super
18
- @source = step_body['upload']
19
- @destination = step_body['destination']
20
- end
21
-
22
- def transpile
23
- code = String.new(" ")
24
- code << "$#{@name} = " if @name
16
+ # Returns an array of arguments to pass to the step's function call
17
+ #
18
+ private def format_args(body)
19
+ opts = format_options(body)
25
20
 
26
- fn = 'upload_file'
27
- args = [@source, @destination, @targets]
28
- args << @description if @description
21
+ args = [body['upload'], body['destination'], body['targets']]
22
+ args << body['description'] if body['description']
23
+ args << opts if opts.any?
29
24
 
30
- code << function_call(fn, args)
25
+ args
26
+ end
31
27
 
32
- code << "\n"
28
+ # Returns the function corresponding to the step
29
+ #
30
+ private def function
31
+ 'upload_file'
33
32
  end
34
33
  end
35
34
  end
@@ -29,7 +29,7 @@ module Bolt
29
29
 
30
30
  plan_string = String.new('')
31
31
  plan_string << "# #{plan_object.description}\n" if plan_object.description
32
- plan_string << "# WARNING: This is an autogenerated plan. It may not behave as expected.\n"
32
+ plan_string << "# WARNING: This is an autogenerated plan. It might not behave as expected.\n"
33
33
  plan_string << "# @private #{plan_object.private}\n" unless plan_object.private.nil?
34
34
  plan_string << "#{param_descriptions}\n" unless param_descriptions.empty?
35
35
 
@@ -22,7 +22,7 @@ module Bolt
22
22
  Invalid plan name '#{plan_name}'. Plan names are composed of one or more name segments
23
23
  separated by double colons '::'.
24
24
 
25
- Each name segment must begin with a lowercase letter, and may only include lowercase
25
+ Each name segment must begin with a lowercase letter, and can only include lowercase
26
26
  letters, digits, and underscores.
27
27
 
28
28
  Examples of valid plan names:
@@ -143,7 +143,7 @@ module Bolt
143
143
  @outputter.print_message("Migrating project #{@config.project.path}\n\n")
144
144
 
145
145
  @outputter.print_action_step(
146
- "Migrating a Bolt project may make irreversible changes to the project's "\
146
+ "Migrating a Bolt project might make irreversible changes to the project's "\
147
147
  "configuration and inventory files. Before continuing, make sure the "\
148
148
  "project has a backup or uses a version control system."
149
149
  )
@@ -66,7 +66,7 @@ module Bolt
66
66
  # Attempt to resolve dependencies
67
67
  begin
68
68
  @outputter.print_message('')
69
- @outputter.print_action_step("Resolving module dependencies, this may take a moment")
69
+ @outputter.print_action_step("Resolving module dependencies, this might take a moment")
70
70
  puppetfile = Bolt::ModuleInstaller::Resolver.new.resolve(specs)
71
71
  rescue Bolt::Error => e
72
72
  @outputter.print_action_error("#{e.message}\nSkipping module migration.")
data/lib/bolt/shell.rb CHANGED
@@ -8,6 +8,22 @@ module Bolt
8
8
  @target = target
9
9
  @conn = conn
10
10
  @logger = Bolt::Logger.logger(@target.safe_name)
11
+
12
+ if Bolt::Logger.stream
13
+ Bolt::Logger.warn_once("stream_experimental",
14
+ "The 'stream' option is experimental, and might "\
15
+ "include breaking changes between minor versions.")
16
+ @stream_logger = Bolt::Logger.logger(:stream)
17
+ # Don't send stream messages to the parent logger
18
+ @stream_logger.additive = false
19
+
20
+ # Log stream messages without any other data or color
21
+ pattern = Logging.layouts.pattern(pattern: '%m\n')
22
+ @stream_logger.appenders = Logging.appenders.stdout(
23
+ 'console',
24
+ layout: pattern
25
+ )
26
+ end
11
27
  end
12
28
 
13
29
  def run_command(*_args)
@@ -12,7 +12,6 @@ module Bolt
12
12
  super
13
13
 
14
14
  @run_as = nil
15
-
16
15
  @sudo_id = SecureRandom.uuid
17
16
  @sudo_password = @target.options['sudo-password'] || @target.password
18
17
  end
@@ -54,19 +53,35 @@ module Bolt
54
53
 
55
54
  def download(source, destination, options = {})
56
55
  running_as(options[:run_as]) do
57
- # Target OS may be either Unix or Windows. Without knowing the target OS before-hand
58
- # we can't assume whether the path separator is '/' or '\'. Assume we're connecting
59
- # to a target with Unix and then check if the path exists after downloading.
60
56
  download = File.join(destination, Bolt::Util.unix_basename(source))
61
57
 
62
- conn.download_file(source, destination, download)
58
+ # If using run-as, the file is copied to a tmpdir and chowned to the
59
+ # connecting user. This is a workaround for limitations in net-ssh that
60
+ # only allow for downloading files as the connecting user, which is a
61
+ # problem for users who cannot connect to targets as the root user.
62
+ # This temporary copy should *always* be deleted.
63
+ if run_as
64
+ with_tmpdir(force_cleanup: true) do |dir|
65
+ tmpfile = File.join(dir.to_s, Bolt::Util.unix_basename(source))
66
+
67
+ result = execute(['cp', '-r', source, dir.to_s], sudoable: true)
68
+
69
+ if result.exit_code != 0
70
+ message = "Could not copy file '#{source}' to temporary directory '#{dir}': #{result.stderr.string}"
71
+ raise Bolt::Node::FileError.new(message, 'CP_ERROR')
72
+ end
73
+
74
+ # We need to force the chown, otherwise this will just return
75
+ # without doing anything since the chown user is the same as the
76
+ # connecting user.
77
+ dir.chown(conn.user, force: true)
63
78
 
64
- # If the download path doesn't exist, then the file was likely downloaded from Windows
65
- # using a source path with backslashes (e.g. 'C:\Users\Administrator\foo'). The file
66
- # should be saved to the expected location, so update the download path assuming a
67
- # Windows basename so the result shows the correct local path.
68
- unless File.exist?(download)
69
- download = File.join(destination, Bolt::Util.windows_basename(source))
79
+ conn.download_file(tmpfile, destination, download)
80
+ end
81
+ # If not using run-as, we can skip creating a temporary copy and just
82
+ # download the file directly.
83
+ else
84
+ conn.download_file(source, destination, download)
70
85
  end
71
86
 
72
87
  Bolt::Result.for_download(target, source, destination, download)
@@ -145,7 +160,10 @@ module Bolt
145
160
 
146
161
  dir.chown(run_as)
147
162
 
148
- execute_options[:stdin] = stdin
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
+
149
167
  execute_options[:sudoable] = true if run_as
150
168
  output = execute(remote_task_path, **execute_options)
151
169
  end
@@ -291,12 +309,12 @@ module Bolt
291
309
 
292
310
  # A helper to create and delete a tmpdir on the remote system. Yields the
293
311
  # directory name.
294
- def with_tmpdir
312
+ def with_tmpdir(force_cleanup: false)
295
313
  dir = make_tmpdir
296
314
  yield dir
297
315
  ensure
298
316
  if dir
299
- if target.options['cleanup']
317
+ if target.options['cleanup'] || force_cleanup
300
318
  dir.delete
301
319
  else
302
320
  Bolt::Logger.warn("skip_cleanup", "Skipping cleanup of tmpdir #{dir}")
@@ -390,14 +408,23 @@ module Bolt
390
408
  # See if we can read from out or err, or write to in
391
409
  ready_read, ready_write, = select(read_streams.keys, write_stream, nil, timeout)
392
410
 
393
- # Read from out and err
394
411
  ready_read&.each do |stream|
412
+ stream_name = stream == out ? 'out' : 'err'
395
413
  # Check for sudo prompt
396
- read_streams[stream] << if use_sudo
397
- check_sudo(stream, inp, options[:stdin])
398
- else
399
- stream.readpartial(CHUNK_SIZE)
400
- end
414
+ to_print = if use_sudo
415
+ check_sudo(stream, inp, options[:stdin])
416
+ else
417
+ stream.readpartial(CHUNK_SIZE)
418
+ 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
401
428
  rescue EOFError
402
429
  end
403
430
 
@@ -445,7 +472,7 @@ module Bolt
445
472
  when 0
446
473
  @logger.trace { "Command `#{command_str}` returned successfully" }
447
474
  when 126
448
- msg = "\n\nThis may be caused by the default tmpdir being mounted "\
475
+ msg = "\n\nThis might be caused by the default tmpdir being mounted "\
449
476
  "using 'noexec'. See http://pup.pt/task-failure for details and workarounds."
450
477
  result_output.stderr << msg
451
478
  @logger.trace { "Command #{command_str} failed with exit code #{result_output.exit_code}" }
@@ -24,8 +24,8 @@ module Bolt
24
24
  end
25
25
  end
26
26
 
27
- def chown(owner)
28
- return if owner.nil? || owner == @owner
27
+ def chown(owner, force: false)
28
+ return if owner.nil? || (owner == @owner && !force)
29
29
 
30
30
  result = @shell.execute(['id', '-g', owner])
31
31
  if result.exit_code != 0
@@ -208,16 +208,21 @@ module Bolt
208
208
  arguments = unwrap_sensitive_args(arguments)
209
209
  with_tmpdir do |dir|
210
210
  script_path = write_executable(dir, script)
211
- command = if powershell_file?(script_path)
211
+ command = if powershell_file?(script_path) && options[:pwsh_params]
212
+ # Scripts run with pwsh_params can be run like tasks
213
+ Snippets.ps_task(script_path, options[:pwsh_params])
214
+ elsif powershell_file?(script_path)
212
215
  Snippets.run_script(arguments, script_path)
213
216
  else
214
217
  path, args = *process_from_extension(script_path)
215
218
  args += escape_arguments(arguments)
216
219
  execute_process(path, args)
217
220
  end
218
- command = [*env_declarations(options[:env_vars]), command].join("\r\n") if options[:env_vars]
221
+ env_assignments = options[:env_vars] ? env_declarations(options[:env_vars]) : []
222
+ shell_init = options[:pwsh_params] ? Snippets.shell_init : ''
223
+
224
+ output = execute([shell_init, *env_assignments, command].join("\r\n"))
219
225
 
220
- output = execute(command)
221
226
  Bolt::Result.for_command(target,
222
227
  output.stdout.string,
223
228
  output.stderr.string,
@@ -310,12 +315,26 @@ module Bolt
310
315
  # the proper encoding so the string isn't later misinterpreted
311
316
  encoding = out.external_encoding
312
317
  out.binmode
313
- result.stdout << out.read.force_encoding(encoding)
318
+ to_print = out.read.force_encoding(encoding)
319
+ if !to_print.chomp.empty? && @stream_logger
320
+ formatted = to_print.lines.map do |msg|
321
+ "[#{@target.safe_name}] out: #{msg.chomp}"
322
+ end.join("\n")
323
+ @stream_logger.warn(formatted)
324
+ end
325
+ result.stdout << to_print
314
326
  end
315
327
  stderr = Thread.new do
316
328
  encoding = err.external_encoding
317
329
  err.binmode
318
- result.stderr << err.read.force_encoding(encoding)
330
+ to_print = err.read.force_encoding(encoding)
331
+ if !to_print.chomp.empty? && @stream_logger
332
+ formatted = to_print.lines.map do |msg|
333
+ "[#{@target.safe_name}] err: #{msg.chomp}"
334
+ end.join("\n")
335
+ @stream_logger.warn(formatted)
336
+ end
337
+ result.stderr << to_print
319
338
  end
320
339
 
321
340
  stdout.join
data/lib/bolt/task.rb CHANGED
@@ -148,7 +148,7 @@ module Bolt
148
148
 
149
149
  if unknown_keys.any?
150
150
  msg = "Metadata for task '#{@name}' contains unknown keys: #{unknown_keys.join(', ')}."
151
- msg += " This could be a typo in the task metadata or may result in incorrect behavior."
151
+ msg += " This could be a typo in the task metadata or might result in incorrect behavior."
152
152
  Bolt::Logger.warn("unknown_task_metadata_keys", msg)
153
153
  end
154
154
  end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bolt/logger'
4
+ require 'bolt/node/errors'
5
+ require 'bolt/transport/simple'
6
+
7
+ module Bolt
8
+ module Transport
9
+ class LXD < Simple
10
+ def provided_features
11
+ ['shell']
12
+ end
13
+
14
+ def with_connection(target, options = {})
15
+ Bolt::Logger.warn_once("lxd_experimental",
16
+ "The LXD transport is experimental, and might "\
17
+ "include breaking changes between minor versions.")
18
+ conn = Connection.new(target, options)
19
+ conn.connect
20
+ yield conn
21
+ end
22
+ end
23
+ end
24
+ end
25
+
26
+ require 'bolt/transport/lxd/connection'