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.
- checksums.yaml +4 -4
- data/Puppetfile +1 -2
- data/lib/bolt/bolt_option_parser.rb +63 -1
- data/lib/bolt/cli.rb +1 -1
- data/lib/bolt/config/options.rb +14 -0
- data/lib/bolt/config/transport/choria.rb +74 -0
- data/lib/bolt/config/transport/options.rb +108 -0
- data/lib/bolt/executor.rb +2 -0
- data/lib/bolt/pal/yaml_plan/transpiler.rb +1 -1
- data/lib/bolt/plugin/puppetdb.rb +1 -1
- data/lib/bolt/plugin.rb +1 -4
- data/lib/bolt/puppetdb/config.rb +8 -0
- data/lib/bolt/puppetdb/instance.rb +1 -0
- data/lib/bolt/result_set.rb +1 -1
- data/lib/bolt/transport/choria/agent_discovery.rb +137 -0
- data/lib/bolt/transport/choria/bolt_tasks.rb +248 -0
- data/lib/bolt/transport/choria/client.rb +281 -0
- data/lib/bolt/transport/choria/command_builders.rb +199 -0
- data/lib/bolt/transport/choria/helpers.rb +197 -0
- data/lib/bolt/transport/choria/shell.rb +560 -0
- data/lib/bolt/transport/choria.rb +218 -0
- data/lib/bolt/transport/winrm/connection.rb +13 -3
- data/lib/bolt/version.rb +1 -1
- data/lib/mcollective/agent/shell.ddl +154 -0
- metadata +31 -10
- data/lib/bolt/plugin/puppet_connect_data.rb +0 -85
- data/modules/puppet_connect/plans/test_input_data.pp +0 -94
|
@@ -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
|