openbolt 5.6.0 → 9.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/Puppetfile +35 -35
  3. data/bolt-modules/boltlib/lib/puppet/datatypes/result.rb +1 -1
  4. data/bolt-modules/boltlib/lib/puppet/datatypes/resultset.rb +2 -2
  5. data/bolt-modules/boltlib/lib/puppet/datatypes/target.rb +2 -2
  6. data/bolt-modules/boltlib/lib/puppet/functions/apply_prep.rb +2 -2
  7. data/bolt-modules/boltlib/lib/puppet/functions/background.rb +1 -1
  8. data/bolt-modules/boltlib/lib/puppet/functions/fail_plan.rb +1 -1
  9. data/bolt-modules/boltlib/lib/puppet/functions/get_resources.rb +1 -1
  10. data/bolt-modules/boltlib/lib/puppet/functions/puppetdb_command.rb +1 -1
  11. data/bolt-modules/boltlib/lib/puppet/functions/puppetdb_query.rb +1 -1
  12. data/bolt-modules/boltlib/lib/puppet/functions/run_plan.rb +27 -4
  13. data/bolt-modules/boltlib/lib/puppet/functions/run_task.rb +14 -22
  14. data/bolt-modules/boltlib/lib/puppet/functions/run_task_with.rb +14 -26
  15. data/bolt-modules/boltlib/lib/puppet/functions/wait.rb +2 -2
  16. data/bolt-modules/file/lib/puppet/functions/file/delete.rb +1 -1
  17. data/bolt-modules/file/lib/puppet/functions/file/exists.rb +1 -1
  18. data/bolt-modules/file/lib/puppet/functions/file/read.rb +1 -1
  19. data/bolt-modules/file/lib/puppet/functions/file/readable.rb +1 -1
  20. data/bolt-modules/file/lib/puppet/functions/file/write.rb +1 -1
  21. data/bolt-modules/log/lib/puppet/functions/log/debug.rb +1 -1
  22. data/bolt-modules/log/lib/puppet/functions/log/error.rb +1 -1
  23. data/bolt-modules/log/lib/puppet/functions/log/fatal.rb +1 -1
  24. data/bolt-modules/log/lib/puppet/functions/log/info.rb +1 -1
  25. data/bolt-modules/log/lib/puppet/functions/log/trace.rb +1 -1
  26. data/bolt-modules/log/lib/puppet/functions/log/warn.rb +1 -1
  27. data/guides/debugging.yaml +1 -1
  28. data/guides/inventory.yaml +2 -2
  29. data/guides/links.yaml +18 -9
  30. data/guides/logging.yaml +1 -1
  31. data/guides/module.yaml +1 -1
  32. data/guides/modulepath.yaml +1 -1
  33. data/guides/project.yaml +2 -2
  34. data/guides/targets.yaml +3 -1
  35. data/guides/transports.yaml +4 -3
  36. data/lib/bolt/analytics.rb +2 -10
  37. data/lib/bolt/bolt_option_parser.rb +66 -66
  38. data/lib/bolt/cli.rb +21 -2
  39. data/lib/bolt/config/options.rb +18 -18
  40. data/lib/bolt/config/transport/choria.rb +3 -0
  41. data/lib/bolt/config/transport/options.rb +34 -45
  42. data/lib/bolt/executor.rb +4 -0
  43. data/lib/bolt/fiber_executor.rb +46 -2
  44. data/lib/bolt/inventory/options.rb +1 -1
  45. data/lib/bolt/inventory/target.rb +1 -1
  46. data/lib/bolt/module_installer/puppetfile.rb +1 -1
  47. data/lib/bolt/module_installer/specs.rb +1 -1
  48. data/lib/bolt/outputter/human.rb +1 -2
  49. data/lib/bolt/plan_creator.rb +4 -4
  50. data/lib/bolt/plan_future.rb +5 -1
  51. data/lib/bolt/project_manager/inventory_migrator.rb +1 -1
  52. data/lib/bolt/project_manager/module_migrator.rb +1 -1
  53. data/lib/bolt/project_manager.rb +1 -1
  54. data/lib/bolt/shell/bash.rb +7 -4
  55. data/lib/bolt/shell/powershell/snippets.rb +20 -1
  56. data/lib/bolt/shell/powershell.rb +2 -2
  57. data/lib/bolt/transport/base.rb +9 -4
  58. data/lib/bolt/transport/choria/agent_discovery.rb +32 -14
  59. data/lib/bolt/transport/choria/bolt_tasks.rb +64 -40
  60. data/lib/bolt/transport/choria/client.rb +66 -49
  61. data/lib/bolt/transport/choria/command_builders.rb +144 -24
  62. data/lib/bolt/transport/choria/file_transfer.rb +134 -0
  63. data/lib/bolt/transport/choria/helpers.rb +25 -20
  64. data/lib/bolt/transport/choria/shell.rb +255 -148
  65. data/lib/bolt/transport/choria.rb +96 -27
  66. data/lib/bolt/transport/ssh/connection.rb +2 -1
  67. data/lib/bolt/util.rb +13 -0
  68. data/lib/bolt/version.rb +1 -1
  69. data/lib/bolt_spec/plans/mock_executor.rb +4 -0
  70. data/libexec/apply_catalog.rb +3 -0
  71. metadata +22 -7
@@ -23,6 +23,29 @@ module Bolt
23
23
  end
24
24
  end
25
25
 
26
+ # Build the command that creates a tmpdir. On POSIX it is mkdir -m 700
27
+ # without -p, so it fails when the directory the tmpdir goes into
28
+ # doesn't exist, like it does with the SSH transport. The Windows
29
+ # command leaves the tmpdir open only to the account the agent runs as
30
+ # and to the Administrators group, which the directories and files in
31
+ # it inherit, and it fails when it cannot set that.
32
+ #
33
+ # @param target [Bolt::Target] Used for platform detection
34
+ # @param tmpdir [String] Absolute path to the tmpdir on the remote node
35
+ # @return [String] Shell command
36
+ def make_tmpdir_command(target, tmpdir)
37
+ return "mkdir -m 700 #{Shellwords.shellescape(tmpdir)}" unless windows_target?(target)
38
+
39
+ "$ErrorActionPreference = 'Stop'; $dir = '#{ps_escape(tmpdir)}'; " \
40
+ '$null = New-Item -ItemType Directory -Force -Path $dir; ' \
41
+ '$acl = Get-Acl -LiteralPath $dir; $acl.SetAccessRuleProtection($true, $false); ' \
42
+ 'foreach ($sid in @([Security.Principal.WindowsIdentity]::GetCurrent().User, ' \
43
+ "(New-Object Security.Principal.SecurityIdentifier('S-1-5-32-544')))) { " \
44
+ '$acl.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule($sid, ' \
45
+ "'FullControl', 'ContainerInherit, ObjectInherit', 'None', 'Allow'))) }; " \
46
+ 'Set-Acl -LiteralPath $dir -AclObject $acl'
47
+ end
48
+
26
49
  # Build a chmod +x command. Returns nil on Windows (not needed).
27
50
  #
28
51
  # @param target [Bolt::Target] Used for platform detection
@@ -43,19 +66,88 @@ module Bolt
43
66
  "rm -rf #{Shellwords.shellescape(path)}"
44
67
  end
45
68
 
69
+ # Build a command that removes the shell agent's job directories for
70
+ # the given handles, under the agent's default state path. A handle
71
+ # without a directory on the target is skipped, so 1 command can
72
+ # contain the handles of every target it goes to. On Windows a failed
73
+ # removal stops the command, so it exits 1 like a failed rm does.
74
+ #
75
+ # @param target [Bolt::Target] Used for platform detection
76
+ # @param handles [Array<String>] Job handles, each checked to be a UUID
77
+ # @return [String] Shell command
78
+ def remove_job_dirs_command(target, handles)
79
+ if windows_target?(target)
80
+ paths = handles.map { |handle| "'C:/ProgramData/mcollective-shell/#{handle}'" }.join(', ')
81
+ "foreach ($path in @(#{paths})) { if (Test-Path -LiteralPath $path) { " \
82
+ 'Remove-Item -LiteralPath $path -Recurse -Force -ErrorAction Stop } }'
83
+ else
84
+ "rm -rf #{handles.map { |handle| "/var/run/mcollective-shell/#{handle}" }.join(' ')}"
85
+ end
86
+ end
87
+
46
88
  # Build a command that writes base64-encoded content to a file
47
89
  # after decoding the content. Requires base64 CLI on POSIX targets.
48
90
  #
49
91
  # @param target [Bolt::Target] Used for platform detection
50
92
  # @param content_b64 [String] Base64-encoded file content
51
93
  # @param dest [String] Absolute destination path on the remote node
94
+ # @param append [Boolean] Whether to add the content to the end of the file instead of replacing it
52
95
  # @return [String] Shell command
53
- def upload_file_command(target, content_b64, dest)
54
- if windows_target?(target)
96
+ def upload_file_command(target, content_b64, dest, append: false)
97
+ if windows_target?(target) && append
98
+ "$bytes = [Convert]::FromBase64String('#{content_b64}'); " \
99
+ "$stream = [IO.File]::Open('#{ps_escape(dest)}', 'Append', 'Write'); " \
100
+ 'try { $stream.Write($bytes, 0, $bytes.Length) } finally { $stream.Close() }'
101
+ elsif windows_target?(target)
55
102
  "[IO.File]::WriteAllBytes('#{ps_escape(dest)}', " \
56
103
  "[Convert]::FromBase64String('#{content_b64}'))"
57
104
  else
58
- "printf '%s' #{Shellwords.shellescape(content_b64)} | base64 -d > #{Shellwords.shellescape(dest)}"
105
+ "printf '%s' #{Shellwords.shellescape(content_b64)} | base64 -d #{append ? '>>' : '>'} #{Shellwords.shellescape(dest)}"
106
+ end
107
+ end
108
+
109
+ # The path of the run file in a tmpdir. A script or task command can
110
+ # contain arguments, env var values and task parameters, so it goes to
111
+ # the nodes in this file instead of as the shell agent's start command,
112
+ # which the agent keeps on disk and returns in shell.list replies.
113
+ #
114
+ # @param target [Bolt::Target] Used for platform detection
115
+ # @param tmpdir [String] Absolute path to the tmpdir on the remote node
116
+ # @return [String] Absolute path to the run file
117
+ def run_file_path(target, tmpdir)
118
+ join_path(target, tmpdir, windows_target?(target) ? 'bolt_run.ps1' : 'bolt_run.sh')
119
+ end
120
+
121
+ # Build the content of a run file. On POSIX it removes itself as soon
122
+ # as sh starts reading it, which keeps reading from the open file. A
123
+ # single command then replaces sh through exec, so shell.kill reaches
124
+ # it. A pipeline doesn't, since exec would run printf as its own
125
+ # process with the task input in its arguments. On Windows the content
126
+ # runs in a script block, which would hide a failing last command from
127
+ # powershell.exe, so it exits 1 then, as the command itself would.
128
+ #
129
+ # @param target [Bolt::Target] Used for platform detection
130
+ # @param command [String] The command the run file contains
131
+ # @param pipeline [Boolean] Whether the command is a pipeline, default is false
132
+ # @return [String] File content
133
+ def run_file_content(target, command, pipeline: false)
134
+ return "#{command}\nif (-not $?) { exit 1 }" if windows_target?(target)
135
+
136
+ "rm -f -- \"$0\"\n#{'exec ' unless pipeline}#{command}\n"
137
+ end
138
+
139
+ # Build the start command that runs a run file. On Windows it reads the
140
+ # file and removes it before running what was in it.
141
+ #
142
+ # @param target [Bolt::Target] Used for platform detection
143
+ # @param path [String] Absolute path to the run file
144
+ # @return [String] Shell command
145
+ def run_file_command(target, path)
146
+ if windows_target?(target)
147
+ "$boltRunFile = '#{ps_escape(path)}'; $boltRunScript = [IO.File]::ReadAllText($boltRunFile); " \
148
+ 'Remove-Item -LiteralPath $boltRunFile -Force; & ([ScriptBlock]::Create($boltRunScript))'
149
+ else
150
+ "sh #{Shellwords.shellescape(path)}"
59
151
  end
60
152
  end
61
153
 
@@ -64,46 +156,58 @@ module Bolt
64
156
  #
65
157
  # @param target [Bolt::Target] Used for platform detection
66
158
  # @param command [String] The command to prepend env vars to
67
- # @param env_vars [Hash{String => String}, nil] Variable names to values
159
+ # @param env_vars [Hash{String => Object}, nil] Variable names to values, which become strings
68
160
  # @param context [String] Description for error messages (e.g., 'task argument')
69
161
  # @return [String] Command with env vars prepended
70
162
  def prepend_env_vars(target, command, env_vars, context)
71
163
  return command unless env_vars&.any?
72
164
 
73
- env_vars.each_key { |key| validate_env_key!(key, context) }
74
-
75
165
  if windows_target?(target)
76
- set_stmts = env_vars.map { |key, val| "$env:#{key} = '#{ps_escape(val)}'" }
77
- "#{set_stmts.join('; ')}; & #{command}"
166
+ "#{powershell_env_statements(env_vars, context)}; #{command}"
78
167
  else
168
+ env_vars.each_key { |key| validate_env_key!(key, context) }
79
169
  env_str = env_vars.map { |key, val| "#{key}=#{Shellwords.shellescape(val)}" }.join(' ')
80
170
  "/usr/bin/env #{env_str} #{command}"
81
171
  end
82
172
  end
83
173
 
174
+ # Build the PowerShell statements that set environment variables,
175
+ # after validating each name.
176
+ #
177
+ # @param env_vars [Hash{String => Object}] Variable names to values, which become strings
178
+ # @param context [String] Description for error messages (e.g., 'task argument')
179
+ # @return [String] The statements joined with '; '
180
+ def powershell_env_statements(env_vars, context)
181
+ env_vars.each_key { |key| validate_env_key!(key, context) }
182
+ env_vars.map { |key, val| "$env:#{key} = '#{ps_escape(val.to_s)}'" }.join('; ')
183
+ end
184
+
84
185
  # Build a command that pipes data to another command via stdin.
85
186
  #
86
187
  # @param target [Bolt::Target] Used for platform detection
87
188
  # @param data [String] Data to pipe (typically JSON task arguments)
88
- # @param command [String] The command to receive stdin
189
+ # @param command [String] The command to receive stdin, which on Windows must
190
+ # be a PowerShell command such as one from executable_command
89
191
  # @return [String] Shell command with stdin piping
90
192
  def stdin_pipe_command(target, data, command)
91
193
  if windows_target?(target)
92
194
  # Use a here-string (@'...'@) to avoid escaping issues with
93
- # large JSON payloads. Content between @' and '@ is literal.
94
- "@'\n#{data}\n'@ | & #{command}"
195
+ # large JSON payloads. Its content is literal as long as no line
196
+ # starts with a single quote character and @, which single-line
197
+ # JSON never does.
198
+ "@'\n#{data}\n'@ | #{command}"
95
199
  else
96
200
  "printf '%s' #{Shellwords.shellescape(data)} | #{command}"
97
201
  end
98
202
  end
99
203
 
100
- # Escape a string for use as a shell argument on the target platform.
204
+ # Escape a value for use as a shell argument on the target platform.
101
205
  #
102
206
  # @param target [Bolt::Target] Used for platform detection
103
- # @param str [String] The string to escape
207
+ # @param str [Object] The value to escape, turned into a string first
104
208
  # @return [String] Escaped string (single-quoted on Windows, sh-escaped on POSIX)
105
209
  def escape_arg(target, str)
106
- windows_target?(target) ? "'#{ps_escape(str)}'" : Shellwords.shellescape(str)
210
+ windows_target?(target) ? "'#{ps_escape(str.to_s)}'" : Shellwords.shellescape(str)
107
211
  end
108
212
 
109
213
  # Join path segments using the target platform's separator.
@@ -132,9 +236,22 @@ module Bolt
132
236
  # Escape single quotes for use inside PowerShell single-quoted strings.
133
237
  #
134
238
  # @param str [String] String to escape
135
- # @return [String] String with single quotes doubled
239
+ # @return [String] String with every single quote character doubled
136
240
  def ps_escape(str)
137
- str.gsub("'", "''")
241
+ Bolt::Util.escape_powershell_single_quotes(str)
242
+ end
243
+
244
+ # Build the command that runs an executable, through the interpreter
245
+ # when one is given. On Windows it starts with the call operator,
246
+ # since PowerShell treats a quoted path as a string.
247
+ #
248
+ # @param target [Bolt::Target] Used for platform detection
249
+ # @param path [String] Absolute path to the executable on the remote node
250
+ # @param interpreter [String, Array<String>, nil] Interpreter command, or nil to run the file directly
251
+ # @return [String] Shell command
252
+ def executable_command(target, path, interpreter)
253
+ command = (Array(interpreter) + [path]).map { |part| escape_arg(target, part) }.join(' ')
254
+ windows_target?(target) ? "& #{command}" : command
138
255
  end
139
256
 
140
257
  # Build the full command string for task execution via the shell agent,
@@ -144,14 +261,19 @@ module Bolt
144
261
  # @param target [Bolt::Target] Target (used for platform detection)
145
262
  # @param remote_task_path [String] Absolute path to the task executable on the remote node
146
263
  # @param arguments [Hash] Task parameter names to values
147
- # @param input_method [String] How to pass arguments: 'stdin', 'environment', or 'both'
264
+ # @param input_method [String] How to pass arguments: 'stdin', 'environment', 'both', or 'powershell'
148
265
  # @param interpreter_options [Hash{String => String}] File extension to interpreter path mapping
149
266
  # @return [String] The fully constructed shell command
150
267
  def build_task_command(target, remote_task_path, arguments, input_method, interpreter_options)
268
+ if input_method == 'powershell' && windows_target?(target) && File.extname(remote_task_path).casecmp?('.ps1')
269
+ # The arguments become the script's named parameters, like they
270
+ # do with the WinRM transport
271
+ return Bolt::Shell::Powershell::Snippets.json_functions +
272
+ Bolt::Shell::Powershell::Snippets.ps_task(remote_task_path, arguments)
273
+ end
274
+
151
275
  interpreter = select_interpreter(remote_task_path, interpreter_options)
152
- cmd = interpreter ?
153
- "#{Array(interpreter).map { |part| escape_arg(target, part) }.join(' ')} #{escape_arg(target, remote_task_path)}" :
154
- escape_arg(target, remote_task_path)
276
+ cmd = executable_command(target, remote_task_path, interpreter)
155
277
 
156
278
  needs_env = Bolt::Task::ENVIRONMENT_METHODS.include?(input_method)
157
279
  needs_stdin = Bolt::Task::STDIN_METHODS.include?(input_method)
@@ -161,11 +283,9 @@ module Bolt
161
283
  # requires a script block. Pipeline data doesn't automatically
162
284
  # flow through a script block to inner commands, so we
163
285
  # explicitly forward $input via a pipe.
164
- env_params = envify_params(arguments)
165
- env_params.each_key { |key| validate_env_key!(key, 'task argument') }
166
- set_stmts = env_params.map { |key, val| "$env:#{key} = '#{ps_escape(val)}'" }
286
+ env_statements = powershell_env_statements(envify_params(arguments), 'task argument')
167
287
  cmd = stdin_pipe_command(target, arguments.to_json,
168
- "{ #{set_stmts.join('; ')}; $input | & #{cmd} }")
288
+ "& { #{env_statements}; $input | #{cmd} }")
169
289
  else
170
290
  if needs_env
171
291
  cmd = prepend_env_vars(target, cmd, envify_params(arguments), 'task argument')
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bolt
4
+ module Transport
5
+ class Choria
6
+ # Upload and download through the file_transfer agent. The transfers
7
+ # themselves are done by the client library the agent's Puppet module
8
+ # installs on the controller, MCollective::Util::FileTransfer. This file
9
+ # maps targets, options and results onto it.
10
+
11
+ # The library's outcome kinds as this transport's error kinds. A kind
12
+ # a later library adds is reported as a transfer failure.
13
+ FILE_TRANSFER_ERROR_KINDS = {
14
+ no_response: 'bolt/choria-no-response',
15
+ rpc_error: 'bolt/choria-rpc-error',
16
+ rpc_failed: 'bolt/choria-rpc-failed',
17
+ transfer_failed: 'bolt/choria-file-transfer-failed',
18
+ payload_too_large: 'bolt/choria-payload-too-large',
19
+ checksum_mismatch: 'bolt/choria-checksum-mismatch'
20
+ }.freeze
21
+
22
+ # Upload one file to the capable targets through the library.
23
+ def batch_upload(targets, source, destination, _options = {}, position = [], &callback)
24
+ result_opts = { action: 'upload', name: source, position: position }
25
+ capable, results = prepare_targets(targets, 'file_transfer', result_opts, &callback)
26
+ return results if capable.empty?
27
+
28
+ logger.debug { "Uploading #{source} to #{destination} on #{target_count(capable)}" }
29
+ results + transfer(capable, result_opts, callback) do |client|
30
+ outcomes = client.upload(source, destination, identities_of(capable))
31
+ file_transfer_results(capable, outcomes, result_opts, callback) do |target|
32
+ Bolt::Result.for_upload(target, source, destination)
33
+ end
34
+ end
35
+ end
36
+
37
+ # Download a path from each capable target into its own directory.
38
+ def batch_download(targets, source, destination, _options = {}, position = [], &callback)
39
+ directories = targets.to_h { |target| [target, download_dir(destination, target)] }
40
+ download_into(directories, source, position, &callback)
41
+ end
42
+
43
+ # Download a path from each capable target into the directory given
44
+ # for it.
45
+ #
46
+ # @param directories [Hash{Bolt::Target => String}] Each target and the local directory its download goes into
47
+ # @param source [String] The path to download from each target
48
+ # @param position [Array] Positional info for result tracking
49
+ # @param callback [Proc] Called with :node_start and :node_result events
50
+ # @return [Array<Bolt::Result>]
51
+ def download_into(directories, source, position, &callback)
52
+ result_opts = { action: 'download', name: source, position: position }
53
+ capable, results = prepare_targets(directories.keys, 'file_transfer', result_opts, &callback)
54
+ return results if capable.empty?
55
+
56
+ logger.debug { "Downloading #{source} from #{target_count(capable)}" }
57
+ # The library takes one directory per identity, so targets behind one
58
+ # identity share the last one's directory.
59
+ destination_dirs = capable.to_h { |target| [choria_identity(target), directories.fetch(target)] }
60
+ results + transfer(capable, result_opts, callback) do |client|
61
+ outcomes = client.download(source, destination_dirs)
62
+ file_transfer_results(capable, outcomes, result_opts, callback) do |target, outcome|
63
+ Bolt::Result.for_download(target, source, destination_dirs.fetch(choria_identity(target)), outcome.path)
64
+ end
65
+ end
66
+ end
67
+
68
+ # Runs one transfer for the targets with a library client and returns a
69
+ # result per target. A library that isn't installed on this host, or an
70
+ # error raised for what the controller contains, such as a source that
71
+ # can't be read, is a result for each of the targets.
72
+ #
73
+ # @param targets [Array<Bolt::Target>] The capable targets of one batch
74
+ # @param result_opts [Hash] Options passed through to emit_results (:action, :name, :position)
75
+ # @param callback [Proc, nil] Called with :node_result events
76
+ # @yield [MCollective::Util::FileTransfer::Client] The client for the targets
77
+ # @return [Array<Bolt::Result>]
78
+ def transfer(targets, result_opts, callback)
79
+ yield file_transfer_client(targets)
80
+ rescue Bolt::Error => e
81
+ failures = targets.to_h { |target| [target, error_output(e.message, e.kind)] }
82
+ emit_results(failures, **result_opts, &callback)
83
+ rescue SystemCallError, ArgumentError => e
84
+ logger.warn { "The file transfer raised #{e.class} on the controller: #{e.message}" }
85
+ logger.debug { e.backtrace.join("\n") }
86
+ message = "#{e.message} (#{e.class} on the controller)"
87
+ failures = targets.to_h { |target| [target, error_output(message, FILE_TRANSFER_ERROR_KINDS[:transfer_failed])] }
88
+ emit_results(failures, **result_opts, &callback)
89
+ end
90
+
91
+ # A library client for the targets, with the batch's options. The
92
+ # library builds its connection from the options this transport's own
93
+ # clients get and the RPC mutex every other call in the process takes.
94
+ # A timeout or size the batch does not set is left to the library's
95
+ # default.
96
+ def file_transfer_client(targets)
97
+ load_file_transfer_library
98
+ options = targets.first.options
99
+ settings = { rpc_timeout: options['file-transfer-timeout'], message_data_size: options['message-data-size'],
100
+ upload_batch_size: options['upload-batch-size'] }
101
+ MCollective::Util::FileTransfer::Client.new(client_options(targets), mutex: @rpc_mutex, logger: logger, **settings.compact)
102
+ end
103
+
104
+ # The library is a client file of the agent's Puppet module under the
105
+ # libdir the Choria client config names, which loading that config in
106
+ # configure_client put on the load path.
107
+ def load_file_transfer_library
108
+ require 'mcollective/util/file_transfer'
109
+ rescue LoadError => e
110
+ raise Bolt::Error.new(
111
+ 'The file_transfer client library is not installed on this host. Apply the mcollective_agent_file_transfer ' \
112
+ "module with client => true where OpenBolt runs, which installs it under the Choria libdir (#{e.class}: #{e.message}).",
113
+ 'bolt/choria-file-transfer-library'
114
+ )
115
+ end
116
+
117
+ # A result per target from the outcomes by identity, firing
118
+ # node_result for each. The block builds the result of a success.
119
+ def file_transfer_results(targets, outcomes, result_opts, callback)
120
+ targets.map do |target|
121
+ outcome = outcomes.fetch(choria_identity(target))
122
+ result = if outcome.success?
123
+ yield(target, outcome)
124
+ else
125
+ kind = FILE_TRANSFER_ERROR_KINDS.fetch(outcome.error_kind, FILE_TRANSFER_ERROR_KINDS[:transfer_failed])
126
+ build_result(target, error_output(outcome.error_message, kind), **result_opts)
127
+ end
128
+ callback&.call(type: :node_result, result: result)
129
+ result
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
@@ -14,7 +14,7 @@ module Bolt
14
14
  WINDOWS_PATH_REGEX = %r{\A[A-Za-z]:[\\/]}
15
15
 
16
16
  def target_count(targets)
17
- count = targets.is_a?(Hash) ? targets.size : targets.length
17
+ count = targets.size
18
18
  "#{count} #{count == 1 ? 'target' : 'targets'}"
19
19
  end
20
20
 
@@ -26,11 +26,11 @@ module Bolt
26
26
  #
27
27
  # @param targets [Array, Hash] Initial targets to poll (duped internally)
28
28
  # @param timeout [Numeric] Maximum seconds before exiting
29
- # @param context [String] Label for log messages
29
+ # @param context [String] Label for log and error messages
30
30
  # @return [Hash] with keys:
31
- # - :completed [Hash{Target => Hash}] All finished target outputs
32
- # - :remaining [Array, Hash] Targets still pending when the loop exited
33
- # - :rpc_persistent_failure [Boolean] True if loop exited due to persistent RPC failures
31
+ # - :completed [Hash{Target => Hash}] All finished target outputs, plus a poll
32
+ # error for each pending target after RPC_FAILURE_RETRIES failed polls in a row
33
+ # - :remaining [Array, Hash] Targets still pending at the deadline
34
34
  def poll_with_retries(targets, timeout, context)
35
35
  deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
36
36
  remaining = targets.dup
@@ -62,8 +62,17 @@ module Bolt
62
62
  break if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
63
63
  end
64
64
 
65
- { completed: completed, remaining: remaining,
66
- rpc_persistent_failure: poll_failures >= RPC_FAILURE_RETRIES }
65
+ if poll_failures >= RPC_FAILURE_RETRIES
66
+ (remaining.is_a?(Hash) ? remaining.keys : remaining).each do |target|
67
+ completed[target] = error_output(
68
+ "RPC requests to poll #{context} on #{target.safe_name} failed persistently",
69
+ 'bolt/choria-poll-failed'
70
+ )
71
+ end
72
+ remaining.clear
73
+ end
74
+
75
+ { completed: completed, remaining: remaining }
67
76
  end
68
77
 
69
78
  # Build a Bolt::Result from an output hash. Handles both success and
@@ -100,19 +109,16 @@ module Bolt
100
109
  end
101
110
 
102
111
  # Convert a hash of { target => output } into Results, fire callbacks,
103
- # and return the Results array. When fire_node_start is true, fires a
104
- # :node_start callback before each :node_result.
112
+ # and return the Results array.
105
113
  #
106
114
  # @param target_outputs [Hash{Bolt::Target => Hash}] Map of targets to output hashes
107
115
  # @param action [String] One of 'task', 'command', or 'script'
108
116
  # @param name [String] Task/command/script name for result metadata
109
117
  # @param position [Array] Positional info for result tracking
110
- # @param fire_node_start [Boolean] Whether to emit :node_start before each result
111
- # @param callback [Proc] Called with :node_start and :node_result events
118
+ # @param callback [Proc] Called with :node_result events
112
119
  # @return [Array<Bolt::Result>] Results for all targets in the hash
113
- def emit_results(target_outputs, action:, name:, position:, fire_node_start: false, &callback)
120
+ def emit_results(target_outputs, action:, name:, position:, &callback)
114
121
  target_outputs.map do |target, data|
115
- callback&.call(type: :node_start, target: target) if fire_node_start
116
122
  result = build_result(target, data, action: action, name: name, position: position)
117
123
  callback&.call(type: :node_result, result: result)
118
124
  result
@@ -120,8 +126,8 @@ module Bolt
120
126
  end
121
127
 
122
128
  # Build an output hash from command/task output.
123
- def output(stdout: nil, stderr: nil, exitcode: nil)
124
- { stdout: stdout || '', stderr: stderr || '', exitcode: exitcode || 0 }
129
+ def output(stdout:, stderr:, exitcode:)
130
+ { stdout: stdout || '', stderr: stderr || '', exitcode: exitcode }
125
131
  end
126
132
 
127
133
  # Build an error output hash. When actual output is available (e.g.
@@ -131,15 +137,14 @@ module Bolt
131
137
  .merge(error: message, error_kind: kind)
132
138
  end
133
139
 
134
- # Extract exit code from RPC response data, defaulting to 1 with a
140
+ # Return the exit code an agent reported, defaulting to 1 with a
135
141
  # warning if the agent returned nil.
136
142
  #
137
- # @param data [Hash] RPC response data containing :exitcode
143
+ # @param exitcode [Integer, nil] The exit code from the RPC response data
138
144
  # @param target [Bolt::Target] Target for logging context
139
145
  # @param context [String] Human-readable label for the log message
140
- # @return [Integer] The exit code from the data, or 1 if nil
141
- def exitcode_from(data, target, context)
142
- exitcode = data[:exitcode] || data['exitcode']
146
+ # @return [Integer] The exit code, or 1 if nil
147
+ def exitcode_from(exitcode, target, context)
143
148
  if exitcode.nil?
144
149
  logger.warn {
145
150
  "Agent on #{target.safe_name} returned no exit code for #{context}. " \