openbolt 5.4.0 → 5.5.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.
@@ -0,0 +1,560 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bolt
4
+ module Transport
5
+ class Choria
6
+ # Terminal shell job statuses that indicate the process has finished.
7
+ SHELL_DONE_STATUSES = %w[stopped failed].freeze
8
+
9
+ # Run a command on targets via the shell agent. Assumes all targets in
10
+ # the batch are the same platform (POSIX or Windows). Mixed-platform
11
+ # batches use the first capable target's platform for command syntax.
12
+ #
13
+ # @param targets [Array<Bolt::Target>] Targets in a single collective batch
14
+ # @param command [String] Shell command to execute
15
+ # @param options [Hash] Execution options - supports :env_vars for environment variables
16
+ # @param position [Array] Positional info for result tracking
17
+ # @param callback [Proc] Called with :node_start and :node_result events
18
+ # @return [Array<Bolt::Result>] Results for all targets
19
+ def batch_command(targets, command, options = {}, position = [], &callback)
20
+ result_opts = { action: 'command', name: command, position: position }
21
+ shell_targets, results = prepare_targets(targets, 'shell', result_opts, &callback)
22
+ return results if shell_targets.empty?
23
+
24
+ logger.debug { "Running command via shell agent on #{target_count(shell_targets)}" }
25
+
26
+ first_target = shell_targets.first
27
+ 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) }
31
+
32
+ pending, start_failures = shell_start(shell_targets, command)
33
+ results += emit_results(start_failures, **result_opts, &callback)
34
+ results += emit_results(wait_for_shell_results(pending, timeout), **result_opts, &callback)
35
+
36
+ results
37
+ end
38
+
39
+ # Run a script on targets via the shell agent. Assumes all targets in
40
+ # the batch are the same platform (POSIX or Windows). Mixed-platform
41
+ # batches use the first capable target's platform for infrastructure
42
+ # commands (mkdir, upload, chmod, cleanup).
43
+ #
44
+ # @param targets [Array<Bolt::Target>] Targets in a single collective batch
45
+ # @param script [String] Local path to the script file
46
+ # @param arguments [Array<String>] Command-line arguments to pass to the script
47
+ # @param options [Hash] Execution options; supports :script_interpreter
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>] Results for all targets
51
+ def batch_script(targets, script, arguments, options = {}, position = [], &callback)
52
+ result_opts = { action: 'script', name: script, position: position }
53
+ shell_targets, results = prepare_targets(targets, 'shell', result_opts, &callback)
54
+ return results if shell_targets.empty?
55
+
56
+ logger.debug { "Running script via shell agent on #{target_count(shell_targets)}" }
57
+
58
+ first_target = shell_targets.first
59
+ arguments = unwrap_sensitive_args(arguments)
60
+ timeout = first_target.options['command-timeout']
61
+ tmpdir = generate_tmpdir_path(first_target)
62
+
63
+ script_content = File.binread(script)
64
+
65
+ shell_targets.each { |target| callback&.call(type: :node_start, target: target) }
66
+
67
+ begin
68
+ remote_path = join_path(first_target, tmpdir, File.basename(script))
69
+ active_targets = shell_targets.dup
70
+
71
+ # Create a temp directory with restricted permissions
72
+ failures = shell_run(active_targets,
73
+ make_dir_command(first_target, tmpdir),
74
+ description: 'mkdir tmpdir')
75
+ results += emit_results(failures, **result_opts, &callback)
76
+ active_targets -= failures.keys
77
+
78
+ # Upload the script file
79
+ 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
83
+ end
84
+
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
91
+ end
92
+
93
+ # Execute the script asynchronously and poll for completion
94
+ 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(' '))
102
+ results += emit_results(start_failures, **result_opts, &callback)
103
+ results += emit_results(wait_for_shell_results(pending, timeout), **result_opts, &callback)
104
+ end
105
+ ensure
106
+ cleanup_tmpdir(shell_targets, tmpdir)
107
+ end
108
+
109
+ results
110
+ end
111
+
112
+ # Generate a unique remote tmpdir path for batch operations.
113
+ #
114
+ # @param target [Bolt::Target] Target whose platform and tmpdir config determine the base path
115
+ # @return [String] Absolute path to a unique temporary directory
116
+ def generate_tmpdir_path(target)
117
+ base = target.options['tmpdir']
118
+ base = 'C:\Windows\Temp' if base == '/tmp' && windows_target?(target)
119
+ join_path(target, base, "bolt-choria-#{SecureRandom.uuid}")
120
+ end
121
+
122
+ # Clean up a remote tmpdir on targets, logging per-target failures.
123
+ # Used in ensure blocks after batch_script and batch_task_shell.
124
+ #
125
+ # @param targets [Array<Bolt::Target>] Targets to clean up on
126
+ # @param tmpdir [String] Absolute path to the temporary directory to remove
127
+ def cleanup_tmpdir(targets, tmpdir)
128
+ return unless targets.first.options.fetch('cleanup', true)
129
+
130
+ unless File.basename(tmpdir).start_with?('bolt-choria-')
131
+ logger.warn { "Refusing to delete unexpected tmpdir path: #{tmpdir}" }
132
+ return
133
+ end
134
+
135
+ begin
136
+ failures = shell_run(targets, cleanup_dir_command(targets.first, tmpdir),
137
+ description: 'cleanup tmpdir')
138
+ failures.each do |target, failure|
139
+ logger.warn { "Cleanup failed on #{target.safe_name}. Task data may remain in #{tmpdir}. #{failure[:error]}" }
140
+ end
141
+ rescue StandardError => e
142
+ logger.warn { "Cleanup of #{tmpdir} failed on all targets: #{e.message}" }
143
+ end
144
+ end
145
+
146
+ # Run a task via the shell agent. Groups targets by implementation to
147
+ # support mixed-platform batches. Starts all groups before polling so
148
+ # tasks execute concurrently on nodes across implementations.
149
+ #
150
+ # @param targets [Array<Bolt::Target>] Targets that have the shell agent
151
+ # @param task [Bolt::Task] Task to execute
152
+ # @param arguments [Hash] Task parameter names to values
153
+ # @param result_opts [Hash] Options passed through to emit_results (:action, :name, :position)
154
+ # @param callback [Proc] Called with :node_start and :node_result events
155
+ # @return [Array<Bolt::Result>] Results for all targets
156
+ def run_task_via_shell(targets, task, arguments, result_opts, &callback)
157
+ logger.debug { "Running task #{task.name} via shell agent on #{target_count(targets)}" }
158
+ results = []
159
+ all_pending = {}
160
+ cleanup_entries = []
161
+
162
+ # Each implementation group gets its own tmpdir because different
163
+ # platforms need different base paths (e.g., /tmp vs C:\Windows\Temp).
164
+ 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,
167
+ arguments, result_opts, &callback)
168
+ results += start_result[:failed_results]
169
+ all_pending.merge!(start_result[:pending])
170
+ cleanup_entries << { targets: impl_targets, tmpdir: start_result[:tmpdir] }
171
+ end
172
+
173
+ # Poll all handles in one loop. Unlike bolt_tasks (which needs
174
+ # 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
179
+ ensure
180
+ cleanup_entries.each { |entry| cleanup_tmpdir(entry[:targets], entry[:tmpdir]) }
181
+ end
182
+
183
+ results
184
+ end
185
+
186
+ # Upload task files and start execution for one implementation group.
187
+ #
188
+ # @param targets [Array<Bolt::Target>] Targets sharing the same implementation
189
+ # @param task [Bolt::Task] Task being executed
190
+ # @param implementation [Hash] Task implementation with 'path', 'name', 'input_method', 'files' keys
191
+ # @param arguments [Hash] Task parameter names to values
192
+ # @param result_opts [Hash] Options passed through to emit_results (:action, :name, :position)
193
+ # @param callback [Proc] Called with :node_start and :node_result events
194
+ # @return [Hash] with keys:
195
+ # - :failed_results [Array<Bolt::Result>] Error results from setup phase
196
+ # - :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)
199
+ arguments = arguments.dup
200
+ executable = implementation['path']
201
+ input_method = implementation['input_method']
202
+ extra_files = implementation['files']
203
+ first_target = targets.first
204
+ tmpdir = generate_tmpdir_path(first_target)
205
+
206
+ executable_content = File.binread(executable)
207
+ extra_file_contents = {}
208
+ extra_files.each do |file|
209
+ validate_file_name!(file['name'])
210
+ extra_file_contents[file['name']] = File.binread(file['path'])
211
+ end
212
+
213
+ failed_results = []
214
+ active_targets = targets.dup
215
+ task_dir = tmpdir
216
+
217
+ # Create the tmpdir
218
+ failures = shell_run(active_targets,
219
+ make_dir_command(first_target, tmpdir),
220
+ description: 'mkdir tmpdir')
221
+ failed_results += emit_results(failures, **result_opts, &callback)
222
+ active_targets -= failures.keys
223
+
224
+ # Tasks with extra files get a module-layout directory tree in
225
+ # tmpdir, and _installdir is set so the task can find them.
226
+ # Simple tasks go directly in tmpdir with no _installdir.
227
+ if active_targets.any? && extra_files.any?
228
+ arguments['_installdir'] = tmpdir
229
+ task_dir = join_path(first_target, tmpdir, task.tasks_dir)
230
+
231
+ # Create subdirectories for the task and its dependencies
232
+ extra_dirs = extra_files.map { |file| join_path(first_target, tmpdir, File.dirname(file['name'])) }.uniq
233
+ 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')
237
+ failed_results += emit_results(failures, **result_opts, &callback)
238
+ active_targets -= failures.keys
239
+
240
+ # Upload each dependency file to its module-relative path
241
+ extra_files.each do |file|
242
+ break if active_targets.empty?
243
+
244
+ failures = upload_file_content(active_targets, extra_file_contents[file['name']],
245
+ join_path(first_target, tmpdir, file['name']))
246
+ failed_results += emit_results(failures, **result_opts, &callback)
247
+ active_targets -= failures.keys
248
+ end
249
+ end
250
+
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
257
+ end
258
+
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
265
+ end
266
+
267
+ # Start the task asynchronously
268
+ 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)
273
+ failed_results += emit_results(start_failures, **result_opts, &callback)
274
+ end
275
+
276
+ { failed_results: failed_results, pending: pending, tmpdir: tmpdir }
277
+ end
278
+
279
+ # Execute a synchronous command on targets via the shell.run RPC action.
280
+ # Used for internal prep/cleanup (mkdir, chmod, etc.) that completes quickly.
281
+ # Returns only failures since successes don't need to be reported.
282
+ #
283
+ # @param targets [Array<Bolt::Target>] Targets to run the command on
284
+ # @param command [String] Shell command to execute
285
+ # @param description [String, nil] Human-readable label for logging (defaults to command)
286
+ # @return [Hash{Bolt::Target => Hash}] Failures only; empty hash means all succeeded
287
+ def shell_run(targets, command, description: nil)
288
+ label = description || command
289
+ command = powershell_cmd(command) if windows_target?(targets.first)
290
+ response = rpc_request('shell', targets, label) do |client|
291
+ client.run(command: command)
292
+ end
293
+
294
+ # Check that the exit code is 0 for each successful RPC response,
295
+ # treating nonzero exit codes as failures.
296
+ failures = response[:errors]
297
+ response[:responded].each do |target, data|
298
+ data ||= {}
299
+ exitcode = exitcode_from(data, target, label)
300
+ next if exitcode.zero?
301
+
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
+ )
307
+ end
308
+
309
+ failures
310
+ end
311
+
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.
321
+ #
322
+ # @param targets [Array<Bolt::Target>] Targets to upload to
323
+ # @param content [String] Raw file content (binary-safe)
324
+ # @param destination [String] Absolute path on the remote node
325
+ # @return [Hash{Bolt::Target => Hash}] Failures only; empty hash means all succeeded
326
+ def upload_file_content(targets, content, destination)
327
+ 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}")
331
+ end
332
+
333
+ # Start an async command on targets via the shell.start RPC action.
334
+ # Returns handles for polling with wait_for_shell_results.
335
+ #
336
+ # @param targets [Array<Bolt::Target>] Targets to start the command on
337
+ # @param command [String] Shell command to execute
338
+ # @return [Array] Two-element array:
339
+ # - pending [Hash] Targets mapped to { handle: uuid_string }
340
+ # - failures [Hash] Targets mapped to error output hashes
341
+ def shell_start(targets, command)
342
+ command = powershell_cmd(command) if windows_target?(targets.first)
343
+ response = rpc_request('shell', targets, 'shell.start') do |client|
344
+ client.start(command: command)
345
+ end
346
+ failures = response[:errors]
347
+
348
+ pending, no_handle = response[:responded].partition { |_target, data| data&.dig(:handle) }.map(&:to_h)
349
+ pending.each { |target, data| logger.debug { "Started command on #{target.safe_name}, handle: #{data[:handle]}" } }
350
+
351
+ no_handle.each_key do |target|
352
+ failures[target] = error_output("shell.start on #{target.safe_name} returned success but no handle",
353
+ 'bolt/choria-missing-handle')
354
+ end
355
+
356
+ [pending, failures]
357
+ end
358
+
359
+ # Wait for async shell handles to complete, fetch their output via
360
+ # shell_statuses, and kill timed-out processes.
361
+ #
362
+ # @param pending [Hash{Bolt::Target => Hash}] Targets to poll, each mapped to { handle: uuid_string }
363
+ # @param timeout [Numeric] Maximum seconds to wait before killing remaining processes
364
+ # @return [Hash{Bolt::Target => Hash}] Output hash for every target (success and error)
365
+ def wait_for_shell_results(pending, timeout)
366
+ return {} if pending.empty?
367
+
368
+ 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
391
+ end
392
+
393
+ { rpc_failed: false, done: done }
394
+ end
395
+
396
+ remaining_errors = {}
397
+ 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
413
+ end
414
+ end
415
+
416
+ poll_result[:completed].merge(remaining_errors)
417
+ end
418
+
419
+ # One round of the shell.list RPC action to check which handles have
420
+ # completed. Targets not yet done are omitted from the return value.
421
+ #
422
+ # @param remaining [Hash{Bolt::Target => Hash}] Targets still pending, each mapped to
423
+ # { handle: uuid_string }
424
+ # @return [Array] Two-element array:
425
+ # - done [Hash{Bolt::Target => Hash}] Completed targets mapped to handle state or error hash
426
+ # - rpc_failed [Boolean] True when the entire RPC call failed
427
+ def shell_list(remaining)
428
+ response = rpc_request('shell', remaining.keys, 'shell.list') do |client|
429
+ client.list
430
+ end
431
+ return [{}, true] if response[:rpc_failed]
432
+
433
+ done = response[:errors]
434
+ logger.debug { "shell.list: #{target_count(response[:responded])} responded, #{target_count(done)} failed" } unless done.empty?
435
+
436
+ response[:responded].each do |target, data|
437
+ if data.nil?
438
+ done[target] = error_output("shell.list on #{target.safe_name} returned success but no data",
439
+ 'bolt/choria-missing-data')
440
+ next
441
+ end
442
+
443
+ handle = remaining[target][:handle]
444
+ job = data.dig(:jobs, handle)
445
+
446
+ unless job
447
+ logger.debug {
448
+ job_handles = data[:jobs]&.keys || []
449
+ "shell.list on #{target.safe_name}: handle #{handle} not found, " \
450
+ "available handles: #{job_handles.inspect}"
451
+ }
452
+ done[target] = error_output(
453
+ "Handle #{handle} not found in shell.list on #{target.safe_name}. " \
454
+ "The process may have been cleaned up or the agent may have restarted.",
455
+ 'bolt/choria-handle-not-found'
456
+ )
457
+ next
458
+ end
459
+
460
+ status = job['status']&.to_s
461
+ logger.debug { "shell.list on #{target.safe_name}: handle #{handle} status: #{status}" }
462
+ done[target] = remaining[target] if SHELL_DONE_STATUSES.include?(status)
463
+ end
464
+
465
+ [done, false]
466
+ end
467
+
468
+ # Fetch stdout/stderr/exitcode from completed targets via the
469
+ # shell.statuses RPC action. Requires shell agent >= 1.2.1.
470
+ #
471
+ # @param targets [Hash{Bolt::Target => Hash}] Completed targets mapped to { handle: uuid_string }
472
+ # @return [Hash{Bolt::Target => Hash}] Output hash for each target
473
+ def shell_statuses(targets)
474
+ handles = targets.transform_values { |data| data[:handle] }
475
+ logger.debug { "Fetching shell.statuses for #{target_count(targets.keys)}" }
476
+
477
+ results = {}
478
+ response = rpc_request('shell', targets.keys, 'shell.statuses') do |client|
479
+ client.statuses(handles: handles.values)
480
+ end
481
+
482
+ response[:errors].each do |target, fail_output|
483
+ results[target] = fail_output
484
+ end
485
+
486
+ response[:responded].each do |target, data|
487
+ statuses = data&.dig(:statuses)
488
+ handle = handles[target]
489
+
490
+ unless statuses
491
+ results[target] = error_output(
492
+ "shell.statuses on #{target.safe_name} returned no data",
493
+ 'bolt/choria-missing-data'
494
+ )
495
+ next
496
+ end
497
+
498
+ status_data = statuses[handle]
499
+ unless status_data
500
+ results[target] = error_output(
501
+ "shell.statuses on #{target.safe_name} did not include handle #{handle}",
502
+ 'bolt/choria-missing-data'
503
+ )
504
+ next
505
+ end
506
+
507
+ status = status_data['status']&.to_s
508
+ stdout = status_data['stdout']
509
+ stderr = status_data['stderr']
510
+ error_msg = status_data['error']
511
+
512
+ if status == 'error'
513
+ results[target] = error_output(
514
+ "Handle #{handle} not found on #{target.safe_name}: #{error_msg}",
515
+ 'bolt/choria-handle-not-found'
516
+ )
517
+ elsif status == 'failed'
518
+ results[target] = error_output(
519
+ "Process failed on #{target.safe_name}: #{stderr}",
520
+ 'bolt/choria-process-failed',
521
+ stdout: stdout, stderr: stderr, exitcode: 1
522
+ )
523
+ else
524
+ exitcode = exitcode_from(status_data, target, 'shell.statuses')
525
+ results[target] = output(stdout: stdout, stderr: stderr, exitcode: exitcode)
526
+ end
527
+ end
528
+
529
+ results
530
+ rescue StandardError => e
531
+ raise if e.is_a?(Bolt::Error)
532
+
533
+ logger.warn { "shell.statuses RPC call failed: #{e.class}: #{e.message}" }
534
+ targets.each_key do |target|
535
+ results[target] ||= error_output(
536
+ "Fetching output from #{target.safe_name} failed: #{e.class}: #{e.message}",
537
+ 'bolt/choria-result-processing-error'
538
+ )
539
+ end
540
+ results
541
+ end
542
+
543
+ # Kill processes on timed-out targets. Sequential because each target
544
+ # has a unique handle, requiring a separate shell.kill RPC call per target.
545
+ # A future batched kill action (like shell.statuses) would eliminate this.
546
+ #
547
+ # @param targets [Hash{Bolt::Target => Hash}] Timed-out targets mapped to { handle: uuid_string }
548
+ def kill_timed_out_processes(targets)
549
+ logger.debug { "Killing timed-out processes on #{target_count(targets)}" }
550
+ targets.each do |target, state|
551
+ rpc_request('shell', target, 'shell.kill') do |client|
552
+ client.kill(handle: state[:handle])
553
+ end
554
+ rescue StandardError => e
555
+ logger.warn { "Failed to kill process on #{target.safe_name}: #{e.message}" }
556
+ end
557
+ end
558
+ end
559
+ end
560
+ end