openbolt 9.0.0.pre.rc1 → 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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/Puppetfile +5 -5
  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/wait.rb +2 -2
  14. data/bolt-modules/file/lib/puppet/functions/file/delete.rb +1 -1
  15. data/bolt-modules/file/lib/puppet/functions/file/exists.rb +1 -1
  16. data/bolt-modules/file/lib/puppet/functions/file/read.rb +1 -1
  17. data/bolt-modules/file/lib/puppet/functions/file/readable.rb +1 -1
  18. data/bolt-modules/file/lib/puppet/functions/file/write.rb +1 -1
  19. data/bolt-modules/log/lib/puppet/functions/log/debug.rb +1 -1
  20. data/bolt-modules/log/lib/puppet/functions/log/error.rb +1 -1
  21. data/bolt-modules/log/lib/puppet/functions/log/fatal.rb +1 -1
  22. data/bolt-modules/log/lib/puppet/functions/log/info.rb +1 -1
  23. data/bolt-modules/log/lib/puppet/functions/log/trace.rb +1 -1
  24. data/bolt-modules/log/lib/puppet/functions/log/warn.rb +1 -1
  25. data/guides/targets.yaml +2 -0
  26. data/lib/bolt/bolt_option_parser.rb +53 -53
  27. data/lib/bolt/cli.rb +21 -2
  28. data/lib/bolt/config/options.rb +18 -18
  29. data/lib/bolt/config/transport/choria.rb +3 -0
  30. data/lib/bolt/config/transport/options.rb +34 -11
  31. data/lib/bolt/executor.rb +4 -0
  32. data/lib/bolt/fiber_executor.rb +46 -2
  33. data/lib/bolt/inventory/options.rb +1 -1
  34. data/lib/bolt/plan_future.rb +5 -1
  35. data/lib/bolt/shell/bash.rb +5 -3
  36. data/lib/bolt/shell/powershell/snippets.rb +20 -1
  37. data/lib/bolt/shell/powershell.rb +2 -2
  38. data/lib/bolt/transport/base.rb +9 -4
  39. data/lib/bolt/transport/choria/agent_discovery.rb +32 -14
  40. data/lib/bolt/transport/choria/bolt_tasks.rb +64 -40
  41. data/lib/bolt/transport/choria/client.rb +66 -49
  42. data/lib/bolt/transport/choria/command_builders.rb +144 -24
  43. data/lib/bolt/transport/choria/file_transfer.rb +134 -0
  44. data/lib/bolt/transport/choria/helpers.rb +25 -20
  45. data/lib/bolt/transport/choria/shell.rb +255 -148
  46. data/lib/bolt/transport/choria.rb +96 -27
  47. data/lib/bolt/util.rb +13 -0
  48. data/lib/bolt/version.rb +1 -1
  49. data/lib/bolt_spec/plans/mock_executor.rb +4 -0
  50. data/libexec/apply_catalog.rb +3 -0
  51. metadata +5 -4
@@ -6,6 +6,29 @@ module Bolt
6
6
  # Terminal shell job statuses that indicate the process has finished.
7
7
  SHELL_DONE_STATUSES = %w[stopped failed].freeze
8
8
 
9
+ # The shell agent names each job with SecureRandom.uuid. A handle is
10
+ # checked against this before it goes into a command, since 1 command
11
+ # that removes job directories goes to every target of its group.
12
+ SHELL_HANDLE_PATTERN = /\A\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/
13
+
14
+ # Job directories removed per shell.run call. 40 Windows paths stay
15
+ # well inside the shell DDL's 10,240-character command limit after
16
+ # the PowerShell wrapper's UTF-16LE base64 encoding.
17
+ SHELL_JOB_DIR_BATCH_SIZE = 40
18
+
19
+ # Targets per shell.statuses call. Every target answers for each
20
+ # handle of its call, so 1 call for a large batch of finished jobs
21
+ # would make each reply grow with the batch, past the broker's 1 MiB
22
+ # limit at a few thousand targets.
23
+ SHELL_STATUSES_BATCH_SIZE = 200
24
+
25
+ # Bytes of file content per upload command. The shell DDL caps a
26
+ # command at 10,240 characters and the content goes in as base64. On
27
+ # Windows the PowerShell wrapper then encodes the whole command as
28
+ # UTF-16LE base64 again, so its chunks are smaller.
29
+ POSIX_UPLOAD_CHUNK_BYTES = 6_000
30
+ WINDOWS_UPLOAD_CHUNK_BYTES = 2_000
31
+
9
32
  # Run a command on targets via the shell agent. Assumes all targets in
10
33
  # the batch are the same platform (POSIX or Windows). Mixed-platform
11
34
  # batches use the first capable target's platform for command syntax.
@@ -25,9 +48,17 @@ module Bolt
25
48
 
26
49
  first_target = shell_targets.first
27
50
  timeout = first_target.options['command-timeout']
28
- command = prepend_env_vars(first_target, command, options[:env_vars], 'run_command env_vars')
29
-
30
- shell_targets.each { |target| callback&.call(type: :node_start, target: target) }
51
+ env_vars = options[:env_vars]
52
+ # The agent's shell expands the command before /usr/bin/env sets the
53
+ # variables, so on POSIX a command with shell metacharacters runs in a
54
+ # shell of its own that sees them, like it does with the SSH
55
+ # transport. The characters are the ones Ruby's Process.spawn runs a
56
+ # command through a shell for, with the backtick as \x60. A command
57
+ # without any has nothing for a shell to expand, so it runs straight
58
+ # from /usr/bin/env and shell.kill still reaches it
59
+ needs_shell = command.match?(/[*?{}\[\]<>()~&|\\$;'\x60"\n#]/)
60
+ command = "sh -c #{Shellwords.shellescape(command)}" if env_vars&.any? && needs_shell && !windows_target?(first_target)
61
+ command = prepend_env_vars(first_target, command, env_vars, 'run_command env_vars')
31
62
 
32
63
  pending, start_failures = shell_start(shell_targets, command)
33
64
  results += emit_results(start_failures, **result_opts, &callback)
@@ -44,7 +75,7 @@ module Bolt
44
75
  # @param targets [Array<Bolt::Target>] Targets in a single collective batch
45
76
  # @param script [String] Local path to the script file
46
77
  # @param arguments [Array<String>] Command-line arguments to pass to the script
47
- # @param options [Hash] Execution options; supports :script_interpreter
78
+ # @param options [Hash] Execution options; supports :script_interpreter and :env_vars
48
79
  # @param position [Array] Positional info for result tracking
49
80
  # @param callback [Proc] Called with :node_start and :node_result events
50
81
  # @return [Array<Bolt::Result>] Results for all targets
@@ -59,46 +90,42 @@ module Bolt
59
90
  arguments = unwrap_sensitive_args(arguments)
60
91
  timeout = first_target.options['command-timeout']
61
92
  tmpdir = generate_tmpdir_path(first_target)
93
+ remote_path = join_path(first_target, tmpdir, File.basename(script))
94
+ run_file = run_file_path(first_target, tmpdir)
62
95
 
63
- script_content = File.binread(script)
96
+ # Built before the first shell agent call, so a rejected env var name
97
+ # fails before the script reaches the nodes
98
+ interpreter = select_interpreter(script, first_target.options['interpreters']) if options[:script_interpreter]
99
+ cmd_parts = [executable_command(first_target, remote_path, interpreter)]
100
+ cmd_parts += arguments.map { |arg| escape_arg(first_target, arg) }
101
+ command = prepend_env_vars(first_target, cmd_parts.join(' '), options[:env_vars], 'run_script env_vars')
64
102
 
65
- shell_targets.each { |target| callback&.call(type: :node_start, target: target) }
103
+ script_content = File.binread(script)
66
104
 
67
105
  begin
68
- remote_path = join_path(first_target, tmpdir, File.basename(script))
69
- active_targets = shell_targets.dup
106
+ active_targets = shell_targets
70
107
 
71
108
  # Create a temp directory with restricted permissions
72
- failures = shell_run(active_targets,
73
- make_dir_command(first_target, tmpdir),
74
- description: 'mkdir tmpdir')
109
+ failures = shell_run(active_targets, make_tmpdir_command(first_target, tmpdir), description: "make tmpdir #{tmpdir}")
75
110
  results += emit_results(failures, **result_opts, &callback)
76
111
  active_targets -= failures.keys
77
112
 
78
- # Upload the script file
113
+ # Upload the script file and make it executable
79
114
  if active_targets.any?
80
- failures = upload_file_content(active_targets, script_content, remote_path)
81
- results += emit_results(failures, **result_opts, &callback)
82
- active_targets -= failures.keys
115
+ active_targets, stage_results = stage_executable(active_targets, first_target, script_content,
116
+ remote_path, result_opts, &callback)
117
+ results += stage_results
83
118
  end
84
119
 
85
- # Make the script executable (no-op on Windows)
86
- chmod_cmd = make_executable_command(first_target, remote_path)
87
- if active_targets.any? && chmod_cmd
88
- failures = shell_run(active_targets, chmod_cmd, description: 'chmod script')
89
- results += emit_results(failures, **result_opts, &callback)
90
- active_targets -= failures.keys
120
+ if active_targets.any?
121
+ active_targets, run_file_results = stage_run_file(active_targets, first_target, command, run_file,
122
+ result_opts, &callback)
123
+ results += run_file_results
91
124
  end
92
125
 
93
126
  # Execute the script asynchronously and poll for completion
94
127
  if active_targets.any?
95
- interpreter = select_interpreter(script, first_target.options['interpreters'])
96
- cmd_parts = []
97
- cmd_parts += Array(interpreter).map { |part| escape_arg(first_target, part) } if interpreter && options[:script_interpreter]
98
- cmd_parts << escape_arg(first_target, remote_path)
99
- cmd_parts += arguments.map { |arg| escape_arg(first_target, arg) }
100
-
101
- pending, start_failures = shell_start(active_targets, cmd_parts.join(' '))
128
+ pending, start_failures = shell_start(active_targets, run_file_command(first_target, run_file))
102
129
  results += emit_results(start_failures, **result_opts, &callback)
103
130
  results += emit_results(wait_for_shell_results(pending, timeout), **result_opts, &callback)
104
131
  end
@@ -125,16 +152,17 @@ module Bolt
125
152
  # @param targets [Array<Bolt::Target>] Targets to clean up on
126
153
  # @param tmpdir [String] Absolute path to the temporary directory to remove
127
154
  def cleanup_tmpdir(targets, tmpdir)
128
- return unless targets.first.options.fetch('cleanup', true)
155
+ return unless targets.first.options['cleanup']
129
156
 
130
- unless File.basename(tmpdir).start_with?('bolt-choria-')
157
+ # A Windows target's tmpdir is joined with backslashes, which
158
+ # File.basename does not split on when the controller runs on Linux.
159
+ unless tmpdir.split(%r{[/\\]}).last.to_s.start_with?('bolt-choria-')
131
160
  logger.warn { "Refusing to delete unexpected tmpdir path: #{tmpdir}" }
132
161
  return
133
162
  end
134
163
 
135
164
  begin
136
- failures = shell_run(targets, cleanup_dir_command(targets.first, tmpdir),
137
- description: 'cleanup tmpdir')
165
+ failures = shell_run(targets, cleanup_dir_command(targets.first, tmpdir), description: "remove #{tmpdir}")
138
166
  failures.each do |target, failure|
139
167
  logger.warn { "Cleanup failed on #{target.safe_name}. Task data may remain in #{tmpdir}. #{failure[:error]}" }
140
168
  end
@@ -155,27 +183,26 @@ module Bolt
155
183
  # @return [Array<Bolt::Result>] Results for all targets
156
184
  def run_task_via_shell(targets, task, arguments, result_opts, &callback)
157
185
  logger.debug { "Running task #{task.name} via shell agent on #{target_count(targets)}" }
158
- results = []
186
+ groups, results = group_by_implementation(targets, task, result_opts, &callback)
159
187
  all_pending = {}
160
188
  cleanup_entries = []
161
189
 
162
190
  # Each implementation group gets its own tmpdir because different
163
191
  # platforms need different base paths (e.g., /tmp vs C:\Windows\Temp).
164
192
  begin
165
- targets.group_by { |target| select_implementation(target, task) }.each do |implementation, impl_targets|
166
- start_result = upload_and_start_task(impl_targets, task, implementation,
193
+ groups.each do |implementation, impl_targets|
194
+ tmpdir = generate_tmpdir_path(impl_targets.first)
195
+ cleanup_entries << { targets: impl_targets, tmpdir: tmpdir }
196
+ start_result = upload_and_start_task(impl_targets, task, implementation, tmpdir,
167
197
  arguments, result_opts, &callback)
168
198
  results += start_result[:failed_results]
169
199
  all_pending.merge!(start_result[:pending])
170
- cleanup_entries << { targets: impl_targets, tmpdir: start_result[:tmpdir] }
171
200
  end
172
201
 
173
202
  # Poll all handles in one loop. Unlike bolt_tasks (which needs
174
203
  # separate polls per task_id), shell handles are interchangeable.
175
- unless all_pending.empty?
176
- timeout = targets.first.options['task-timeout']
177
- results += emit_results(wait_for_shell_results(all_pending, timeout), **result_opts, &callback)
178
- end
204
+ timeout = targets.first.options['task-timeout']
205
+ results += emit_results(wait_for_shell_results(all_pending, timeout), **result_opts, &callback)
179
206
  ensure
180
207
  cleanup_entries.each { |entry| cleanup_tmpdir(entry[:targets], entry[:tmpdir]) }
181
208
  end
@@ -188,20 +215,19 @@ module Bolt
188
215
  # @param targets [Array<Bolt::Target>] Targets sharing the same implementation
189
216
  # @param task [Bolt::Task] Task being executed
190
217
  # @param implementation [Hash] Task implementation with 'path', 'name', 'input_method', 'files' keys
218
+ # @param tmpdir [String] Remote tmpdir path to create, which the caller cleans up
191
219
  # @param arguments [Hash] Task parameter names to values
192
220
  # @param result_opts [Hash] Options passed through to emit_results (:action, :name, :position)
193
221
  # @param callback [Proc] Called with :node_start and :node_result events
194
222
  # @return [Hash] with keys:
195
223
  # - :failed_results [Array<Bolt::Result>] Error results from setup phase
196
224
  # - :pending [Hash] Targets mapped to { handle: uuid } for polling
197
- # - :tmpdir [String] Remote tmpdir path for cleanup
198
- def upload_and_start_task(targets, task, implementation, arguments, result_opts, &callback)
225
+ def upload_and_start_task(targets, task, implementation, tmpdir, arguments, result_opts, &callback)
199
226
  arguments = arguments.dup
200
227
  executable = implementation['path']
201
228
  input_method = implementation['input_method']
202
229
  extra_files = implementation['files']
203
230
  first_target = targets.first
204
- tmpdir = generate_tmpdir_path(first_target)
205
231
 
206
232
  executable_content = File.binread(executable)
207
233
  extra_file_contents = {}
@@ -211,13 +237,11 @@ module Bolt
211
237
  end
212
238
 
213
239
  failed_results = []
214
- active_targets = targets.dup
240
+ active_targets = targets
215
241
  task_dir = tmpdir
216
242
 
217
243
  # Create the tmpdir
218
- failures = shell_run(active_targets,
219
- make_dir_command(first_target, tmpdir),
220
- description: 'mkdir tmpdir')
244
+ failures = shell_run(active_targets, make_tmpdir_command(first_target, tmpdir), description: "make tmpdir #{tmpdir}")
221
245
  failed_results += emit_results(failures, **result_opts, &callback)
222
246
  active_targets -= failures.keys
223
247
 
@@ -231,9 +255,8 @@ module Bolt
231
255
  # Create subdirectories for the task and its dependencies
232
256
  extra_dirs = extra_files.map { |file| join_path(first_target, tmpdir, File.dirname(file['name'])) }.uniq
233
257
  all_dirs = [task_dir] + extra_dirs
234
- failures = shell_run(active_targets,
235
- make_dir_command(first_target, *all_dirs),
236
- description: 'mkdir task dirs')
258
+ failures = shell_run(active_targets, make_dir_command(first_target, *all_dirs),
259
+ description: "create subdirectories in '#{tmpdir}'")
237
260
  failed_results += emit_results(failures, **result_opts, &callback)
238
261
  active_targets -= failures.keys
239
262
 
@@ -248,46 +271,100 @@ module Bolt
248
271
  end
249
272
  end
250
273
 
251
- # Upload the main task executable
252
- remote_task_path = join_path(first_target, task_dir, File.basename(executable)) if active_targets.any?
253
- if remote_task_path
254
- failures = upload_file_content(active_targets, executable_content, remote_task_path)
255
- failed_results += emit_results(failures, **result_opts, &callback)
256
- active_targets -= failures.keys
274
+ # Upload the main task executable and make it executable
275
+ if active_targets.any?
276
+ remote_task_path = join_path(first_target, task_dir, File.basename(executable))
277
+ active_targets, stage_results = stage_executable(active_targets, first_target, executable_content,
278
+ remote_task_path, result_opts, &callback)
279
+ failed_results += stage_results
257
280
  end
258
281
 
259
- # Make the task executable (no-op on Windows)
260
- chmod_cmd = make_executable_command(first_target, remote_task_path) if remote_task_path
261
- if active_targets.any? && chmod_cmd
262
- failures = shell_run(active_targets, chmod_cmd, description: 'chmod task')
263
- failed_results += emit_results(failures, **result_opts, &callback)
264
- active_targets -= failures.keys
282
+ run_file = run_file_path(first_target, tmpdir)
283
+ if active_targets.any?
284
+ full_cmd = build_task_command(first_target, remote_task_path, arguments, input_method,
285
+ first_target.options['interpreters'])
286
+ # build_task_command pipes the task input into the task for these methods
287
+ pipeline = Bolt::Task::STDIN_METHODS.include?(input_method)
288
+ active_targets, run_file_results = stage_run_file(active_targets, first_target, full_cmd, run_file,
289
+ result_opts, pipeline: pipeline, &callback)
290
+ failed_results += run_file_results
265
291
  end
266
292
 
267
293
  # Start the task asynchronously
268
294
  pending = {}
269
- if active_targets.any? && remote_task_path
270
- full_cmd = build_task_command(first_target, remote_task_path, arguments, input_method,
271
- first_target.options['interpreters'])
272
- pending, start_failures = shell_start(active_targets, full_cmd)
295
+ if active_targets.any?
296
+ pending, start_failures = shell_start(active_targets, run_file_command(first_target, run_file))
273
297
  failed_results += emit_results(start_failures, **result_opts, &callback)
274
298
  end
275
299
 
276
- { failed_results: failed_results, pending: pending, tmpdir: tmpdir }
300
+ { failed_results: failed_results, pending: pending }
301
+ end
302
+
303
+ # Upload an executable to the same path on the targets and make it
304
+ # executable (no-op on Windows), emitting a result for each target
305
+ # that fails a step.
306
+ #
307
+ # @param targets [Array<Bolt::Target>] Targets to stage the executable on, at least 1
308
+ # @param first_target [Bolt::Target] Target whose platform determines the commands
309
+ # @param content [String] Raw file content (binary-safe)
310
+ # @param remote_path [String] Absolute path on the remote node
311
+ # @param result_opts [Hash] Options passed through to emit_results (:action, :name, :position)
312
+ # @param callback [Proc] Called with :node_result events
313
+ # @return [Array] 2-element array:
314
+ # - [Array<Bolt::Target>] Targets that have the executable in place
315
+ # - [Array<Bolt::Result>] Error results for the targets that failed a step
316
+ def stage_executable(targets, first_target, content, remote_path, result_opts, &callback)
317
+ failures = upload_file_content(targets, content, remote_path)
318
+ results = emit_results(failures, **result_opts, &callback)
319
+ targets -= failures.keys
320
+
321
+ chmod_cmd = make_executable_command(first_target, remote_path)
322
+ if targets.any? && chmod_cmd
323
+ failures = shell_run(targets, chmod_cmd, description: "make file '#{remote_path}' executable")
324
+ results += emit_results(failures, **result_opts, &callback)
325
+ targets -= failures.keys
326
+ end
327
+
328
+ [targets, results]
329
+ end
330
+
331
+ # Upload a job's command to its run file, emitting a result for each
332
+ # target that fails. The command goes to the nodes through shell.run,
333
+ # which removes its own job directory when the upload finishes, so the
334
+ # values in it are not kept the way a shell.start command is.
335
+ #
336
+ # @param targets [Array<Bolt::Target>] Targets to upload the run file to, at least 1
337
+ # @param first_target [Bolt::Target] Target whose platform determines the file content
338
+ # @param command [String] The command the run file contains
339
+ # @param run_file [String] Absolute path to the run file on the remote node
340
+ # @param result_opts [Hash] Options passed through to emit_results (:action, :name, :position)
341
+ # @param pipeline [Boolean] Whether the command is a pipeline, default is false
342
+ # @param callback [Proc] Called with :node_result events
343
+ # @return [Array] 2-element array:
344
+ # - [Array<Bolt::Target>] Targets that have the run file
345
+ # - [Array<Bolt::Result>] Error results for the targets that failed the upload
346
+ def stage_run_file(targets, first_target, command, run_file, result_opts, pipeline: false, &callback)
347
+ content = run_file_content(first_target, command, pipeline: pipeline)
348
+ failures = upload_file_content(targets, content, run_file)
349
+ [targets - failures.keys, emit_results(failures, **result_opts, &callback)]
277
350
  end
278
351
 
279
352
  # Execute a synchronous command on targets via the shell.run RPC action.
280
353
  # Used for internal prep/cleanup (mkdir, chmod, etc.) that completes quickly.
281
- # Returns only failures since successes don't need to be reported.
354
+ # Returns only failures since successes don't need to be reported. A
355
+ # command that exits non-zero gets the file error the other transports
356
+ # report for a failed preparation step, "Could not" followed by the
357
+ # description and the command's stderr. A target that didn't answer the
358
+ # call keeps the error of the call.
282
359
  #
283
360
  # @param targets [Array<Bolt::Target>] Targets to run the command on
284
361
  # @param command [String] Shell command to execute
285
- # @param description [String, nil] Human-readable label for logging (defaults to command)
362
+ # @param description [String] What the command does, as the words that follow "Could not" in
363
+ # the error, like "make tmpdir /tmp/x". It also names the call in logs and RPC errors.
286
364
  # @return [Hash{Bolt::Target => Hash}] Failures only; empty hash means all succeeded
287
- def shell_run(targets, command, description: nil)
288
- label = description || command
365
+ def shell_run(targets, command, description:)
289
366
  command = powershell_cmd(command) if windows_target?(targets.first)
290
- response = rpc_request('shell', targets, label) do |client|
367
+ response = rpc_request('shell', targets, description) do |client|
291
368
  client.run(command: command)
292
369
  end
293
370
 
@@ -296,28 +373,20 @@ module Bolt
296
373
  failures = response[:errors]
297
374
  response[:responded].each do |target, data|
298
375
  data ||= {}
299
- exitcode = exitcode_from(data, target, label)
376
+ exitcode = exitcode_from(data[:exitcode], target, description)
300
377
  next if exitcode.zero?
301
378
 
302
- failures[target] = error_output(
303
- "#{label} failed on #{target.safe_name} (exit code #{exitcode}): #{data[:stderr]}",
304
- 'bolt/choria-operation-failed',
305
- stdout: data[:stdout], stderr: data[:stderr], exitcode: exitcode
306
- )
379
+ failures[target] = error_output("Could not #{description}: #{data[:stderr]}", 'puppetlabs.tasks/task_file_error',
380
+ stdout: data[:stdout], stderr: data[:stderr], exitcode: exitcode)
307
381
  end
308
382
 
309
383
  failures
310
384
  end
311
385
 
312
- # Upload file content to the same path on multiple targets via base64.
313
- # The entire file is base64-encoded and sent as a single RPC message,
314
- # so file size is limited by the NATS max message size (default 1MB,
315
- # configurable via plugin.choria.network.client_max_payload in the
316
- # Choria broker config). Base64 adds ~33% overhead, so the effective
317
- # file size limit is roughly 750KB with default settings.
318
- # Once the file-transfer agent is implemented, we'll use chunked
319
- # transfers via that agent instead when it's available, removing this
320
- # size limitation.
386
+ # Upload file content to the same path on multiple targets via base64,
387
+ # in chunks that each fit in 1 shell.run command. The first chunk
388
+ # replaces the file and the rest add to it. A target that fails a
389
+ # chunk gets no later ones.
321
390
  #
322
391
  # @param targets [Array<Bolt::Target>] Targets to upload to
323
392
  # @param content [String] Raw file content (binary-safe)
@@ -325,9 +394,22 @@ module Bolt
325
394
  # @return [Hash{Bolt::Target => Hash}] Failures only; empty hash means all succeeded
326
395
  def upload_file_content(targets, content, destination)
327
396
  logger.debug { "Uploading #{content.bytesize} bytes to #{destination} on #{target_count(targets)}" }
328
- encoded = Base64.strict_encode64(content)
329
- command = upload_file_command(targets.first, encoded, destination)
330
- shell_run(targets, command, description: "upload #{destination}")
397
+ chunk_size = windows_target?(targets.first) ? WINDOWS_UPLOAD_CHUNK_BYTES : POSIX_UPLOAD_CHUNK_BYTES
398
+ chunks = (0...content.bytesize).step(chunk_size).map { |offset| content.byteslice(offset, chunk_size) }
399
+ chunks = [''] if chunks.empty?
400
+
401
+ failures = {}
402
+ remaining = targets
403
+ chunks.each_with_index do |chunk, index|
404
+ break if remaining.empty?
405
+
406
+ command = upload_file_command(remaining.first, Base64.strict_encode64(chunk), destination,
407
+ append: index.positive?)
408
+ chunk_failures = shell_run(remaining, command, description: "copy file to #{destination}")
409
+ failures.merge!(chunk_failures)
410
+ remaining -= chunk_failures.keys
411
+ end
412
+ failures
331
413
  end
332
414
 
333
415
  # Start an async command on targets via the shell.start RPC action.
@@ -357,37 +439,23 @@ module Bolt
357
439
  end
358
440
 
359
441
  # Wait for async shell handles to complete, fetch their output via
360
- # shell_statuses, and kill timed-out processes.
442
+ # shell_statuses, kill timed-out processes, and then remove the job
443
+ # directories the shell agent keeps for the jobs that finished.
361
444
  #
362
445
  # @param pending [Hash{Bolt::Target => Hash}] Targets to poll, each mapped to { handle: uuid_string }
363
446
  # @param timeout [Numeric] Maximum seconds to wait before killing remaining processes
364
447
  # @return [Hash{Bolt::Target => Hash}] Output hash for every target (success and error)
365
448
  def wait_for_shell_results(pending, timeout)
366
- return {} if pending.empty?
367
-
449
+ finished_jobs = {}
368
450
  poll_result = poll_with_retries(pending, timeout, 'shell.list') do |remaining|
369
- completed, rpc_failed = shell_list(remaining)
370
- next { rpc_failed: true, done: {} } if rpc_failed
371
-
372
- done = {}
373
- fetch_targets = {}
374
- completed.each do |target, value|
375
- if value[:error]
376
- done[target] = value
377
- else
378
- fetch_targets[target] = value
379
- end
380
- end
381
-
382
- unless fetch_targets.empty?
383
- logger.debug { "Fetching output from #{target_count(fetch_targets)}" }
384
- fetched = shell_statuses(fetch_targets)
385
- fetch_targets.each_key do |target|
386
- done[target] = fetched[target] || error_output(
387
- "Command completed on #{target.safe_name} but output could not be fetched",
388
- 'bolt/choria-result-processing-error'
389
- )
390
- end
451
+ finished, failed, rpc_failed = shell_list(remaining)
452
+ next { rpc_failed: true } if rpc_failed
453
+
454
+ finished_jobs.merge!(finished)
455
+ done = failed
456
+ unless finished.empty?
457
+ logger.debug { "Fetching output from #{target_count(finished)}" }
458
+ done.merge!(shell_statuses(finished))
391
459
  end
392
460
 
393
461
  { rpc_failed: false, done: done }
@@ -395,25 +463,20 @@ module Bolt
395
463
 
396
464
  remaining_errors = {}
397
465
  unless poll_result[:remaining].empty?
398
- if poll_result[:rpc_persistent_failure]
399
- poll_result[:remaining].each_key do |target|
400
- remaining_errors[target] = error_output(
401
- "RPC requests to poll shell status on #{target.safe_name} failed persistently",
402
- 'bolt/choria-poll-failed'
403
- )
404
- end
405
- else
406
- kill_timed_out_processes(poll_result[:remaining])
407
- poll_result[:remaining].each_key do |target|
408
- remaining_errors[target] = error_output(
409
- "Command timed out after #{timeout} seconds on #{target.safe_name}",
410
- 'bolt/choria-command-timeout'
411
- )
412
- end
466
+ kill_timed_out_processes(poll_result[:remaining])
467
+ poll_result[:remaining].each_key do |target|
468
+ remaining_errors[target] = error_output(
469
+ "Command timed out after #{timeout} seconds on #{target.safe_name}",
470
+ 'bolt/choria-command-timeout'
471
+ )
413
472
  end
414
473
  end
415
474
 
416
- poll_result[:completed].merge(remaining_errors)
475
+ results = poll_result[:completed].merge(remaining_errors)
476
+ # A job that timed out, or whose node stopped answering, may still be
477
+ # running, so its directory stays
478
+ remove_job_dirs(finished_jobs)
479
+ results
417
480
  end
418
481
 
419
482
  # One round of the shell.list RPC action to check which handles have
@@ -421,22 +484,25 @@ module Bolt
421
484
  #
422
485
  # @param remaining [Hash{Bolt::Target => Hash}] Targets still pending, each mapped to
423
486
  # { handle: uuid_string }
424
- # @return [Array] Two-element array:
425
- # - done [Hash{Bolt::Target => Hash}] Completed targets mapped to handle state or error hash
487
+ # @return [Array] 3-element array:
488
+ # - finished [Hash{Bolt::Target => Hash}] Targets whose process finished, mapped to
489
+ # { handle: uuid_string }
490
+ # - failed [Hash{Bolt::Target => Hash}] Targets whose poll failed, mapped to error hashes
426
491
  # - rpc_failed [Boolean] True when the entire RPC call failed
427
492
  def shell_list(remaining)
428
493
  response = rpc_request('shell', remaining.keys, 'shell.list') do |client|
429
494
  client.list
430
495
  end
431
- return [{}, true] if response[:rpc_failed]
496
+ return [{}, {}, true] if response[:rpc_failed]
432
497
 
433
- done = response[:errors]
434
- logger.debug { "shell.list: #{target_count(response[:responded])} responded, #{target_count(done)} failed" } unless done.empty?
498
+ finished = {}
499
+ failed = response[:errors]
500
+ logger.debug { "shell.list: #{target_count(response[:responded])} responded, #{target_count(failed)} failed" } unless failed.empty?
435
501
 
436
502
  response[:responded].each do |target, data|
437
503
  if data.nil?
438
- done[target] = error_output("shell.list on #{target.safe_name} returned success but no data",
439
- 'bolt/choria-missing-data')
504
+ failed[target] = error_output("shell.list on #{target.safe_name} returned success but no data",
505
+ 'bolt/choria-missing-data')
440
506
  next
441
507
  end
442
508
 
@@ -449,7 +515,7 @@ module Bolt
449
515
  "shell.list on #{target.safe_name}: handle #{handle} not found, " \
450
516
  "available handles: #{job_handles.inspect}"
451
517
  }
452
- done[target] = error_output(
518
+ failed[target] = error_output(
453
519
  "Handle #{handle} not found in shell.list on #{target.safe_name}. " \
454
520
  "The process may have been cleaned up or the agent may have restarted.",
455
521
  'bolt/choria-handle-not-found'
@@ -459,20 +525,31 @@ module Bolt
459
525
 
460
526
  status = job['status']&.to_s
461
527
  logger.debug { "shell.list on #{target.safe_name}: handle #{handle} status: #{status}" }
462
- done[target] = remaining[target] if SHELL_DONE_STATUSES.include?(status)
528
+ finished[target] = remaining[target] if SHELL_DONE_STATUSES.include?(status)
463
529
  end
464
530
 
465
- [done, false]
531
+ [finished, failed, false]
466
532
  end
467
533
 
468
534
  # Fetch stdout/stderr/exitcode from completed targets via the
469
- # shell.statuses RPC action. Requires shell agent >= 1.2.1.
535
+ # shell.statuses RPC action, SHELL_STATUSES_BATCH_SIZE targets per
536
+ # call. Requires shell agent >= 1.2.1.
470
537
  #
471
538
  # @param targets [Hash{Bolt::Target => Hash}] Completed targets mapped to { handle: uuid_string }
472
539
  # @return [Hash{Bolt::Target => Hash}] Output hash for each target
473
540
  def shell_statuses(targets)
541
+ targets.each_slice(SHELL_STATUSES_BATCH_SIZE).with_object({}) do |batch, results|
542
+ results.merge!(shell_statuses_batch(batch.to_h))
543
+ end
544
+ end
545
+
546
+ # One shell.statuses call for the given targets.
547
+ #
548
+ # @param targets [Hash{Bolt::Target => Hash}] Completed targets mapped to { handle: uuid_string }
549
+ # @return [Hash{Bolt::Target => Hash}] Output hash for each target
550
+ def shell_statuses_batch(targets)
474
551
  handles = targets.transform_values { |data| data[:handle] }
475
- logger.debug { "Fetching shell.statuses for #{target_count(targets.keys)}" }
552
+ logger.debug { "Fetching shell.statuses for #{target_count(targets)}" }
476
553
 
477
554
  results = {}
478
555
  response = rpc_request('shell', targets.keys, 'shell.statuses') do |client|
@@ -518,10 +595,10 @@ module Bolt
518
595
  results[target] = error_output(
519
596
  "Process failed on #{target.safe_name}: #{stderr}",
520
597
  'bolt/choria-process-failed',
521
- stdout: stdout, stderr: stderr, exitcode: 1
598
+ stdout: stdout, stderr: stderr
522
599
  )
523
600
  else
524
- exitcode = exitcode_from(status_data, target, 'shell.statuses')
601
+ exitcode = exitcode_from(status_data['exitcode'], target, 'shell.statuses')
525
602
  results[target] = output(stdout: stdout, stderr: stderr, exitcode: exitcode)
526
603
  end
527
604
  end
@@ -548,13 +625,43 @@ module Bolt
548
625
  def kill_timed_out_processes(targets)
549
626
  logger.debug { "Killing timed-out processes on #{target_count(targets)}" }
550
627
  targets.each do |target, state|
551
- rpc_request('shell', target, 'shell.kill') do |client|
628
+ response = rpc_request('shell', target, 'shell.kill') do |client|
552
629
  client.kill(handle: state[:handle])
553
630
  end
554
- rescue StandardError => e
555
- logger.warn { "Failed to kill process on #{target.safe_name}: #{e.message}" }
631
+ response[:errors].each_value do |output|
632
+ logger.warn { "Failed to kill process on #{target.safe_name}: #{output[:error]}" }
633
+ end
556
634
  end
557
635
  end
636
+
637
+ # Remove the job directories of the given handles on the targets whose
638
+ # cleanup option is on. The shell agent's start action keeps a
639
+ # directory per job, with the command and its output, and shell.list
640
+ # returns every one of them, so without this each node's list reply
641
+ # grows with every job OpenBolt ever started there. A node with a
642
+ # custom plugin.shell.state_path keeps its directories, since the
643
+ # client cannot read that setting.
644
+ #
645
+ # @param finished [Hash{Bolt::Target => Hash}] Targets whose job finished, mapped to { handle: uuid_string }
646
+ def remove_job_dirs(finished)
647
+ cleanup_jobs = finished.select { |target, _state| target.options['cleanup'] }
648
+ jobs, unexpected = cleanup_jobs.partition { |_target, state| SHELL_HANDLE_PATTERN.match?(state[:handle].to_s) }
649
+ unexpected.each do |target, state|
650
+ logger.warn { "Not removing the shell job directory on #{target.safe_name} for unexpected handle #{state[:handle].inspect}" }
651
+ end
652
+
653
+ jobs.group_by { |target, _state| windows_target?(target) }.each_value do |platform_jobs|
654
+ platform_jobs.each_slice(SHELL_JOB_DIR_BATCH_SIZE) do |batch|
655
+ targets = batch.map(&:first)
656
+ command = remove_job_dirs_command(targets.first, batch.map { |_target, state| state[:handle] })
657
+ shell_run(targets, command, description: 'remove the shell job directories').each do |target, failure|
658
+ logger.warn { "Removing the shell job directory failed on #{target.safe_name}: #{failure[:error]}" }
659
+ end
660
+ end
661
+ end
662
+ rescue StandardError => e
663
+ logger.warn { "Removing shell job directories failed: #{e.message}" }
664
+ end
558
665
  end
559
666
  end
560
667
  end