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.
- checksums.yaml +4 -4
- data/Puppetfile +5 -5
- data/bolt-modules/boltlib/lib/puppet/datatypes/result.rb +1 -1
- data/bolt-modules/boltlib/lib/puppet/datatypes/resultset.rb +2 -2
- data/bolt-modules/boltlib/lib/puppet/datatypes/target.rb +2 -2
- data/bolt-modules/boltlib/lib/puppet/functions/apply_prep.rb +2 -2
- data/bolt-modules/boltlib/lib/puppet/functions/background.rb +1 -1
- data/bolt-modules/boltlib/lib/puppet/functions/fail_plan.rb +1 -1
- data/bolt-modules/boltlib/lib/puppet/functions/get_resources.rb +1 -1
- data/bolt-modules/boltlib/lib/puppet/functions/puppetdb_command.rb +1 -1
- data/bolt-modules/boltlib/lib/puppet/functions/puppetdb_query.rb +1 -1
- data/bolt-modules/boltlib/lib/puppet/functions/run_plan.rb +27 -4
- data/bolt-modules/boltlib/lib/puppet/functions/wait.rb +2 -2
- data/bolt-modules/file/lib/puppet/functions/file/delete.rb +1 -1
- data/bolt-modules/file/lib/puppet/functions/file/exists.rb +1 -1
- data/bolt-modules/file/lib/puppet/functions/file/read.rb +1 -1
- data/bolt-modules/file/lib/puppet/functions/file/readable.rb +1 -1
- data/bolt-modules/file/lib/puppet/functions/file/write.rb +1 -1
- data/bolt-modules/log/lib/puppet/functions/log/debug.rb +1 -1
- data/bolt-modules/log/lib/puppet/functions/log/error.rb +1 -1
- data/bolt-modules/log/lib/puppet/functions/log/fatal.rb +1 -1
- data/bolt-modules/log/lib/puppet/functions/log/info.rb +1 -1
- data/bolt-modules/log/lib/puppet/functions/log/trace.rb +1 -1
- data/bolt-modules/log/lib/puppet/functions/log/warn.rb +1 -1
- data/guides/targets.yaml +2 -0
- data/lib/bolt/bolt_option_parser.rb +53 -53
- data/lib/bolt/cli.rb +21 -2
- data/lib/bolt/config/options.rb +18 -18
- data/lib/bolt/config/transport/choria.rb +3 -0
- data/lib/bolt/config/transport/options.rb +34 -11
- data/lib/bolt/executor.rb +4 -0
- data/lib/bolt/fiber_executor.rb +46 -2
- data/lib/bolt/inventory/options.rb +1 -1
- data/lib/bolt/plan_future.rb +5 -1
- data/lib/bolt/shell/bash.rb +5 -3
- data/lib/bolt/shell/powershell/snippets.rb +20 -1
- data/lib/bolt/shell/powershell.rb +2 -2
- data/lib/bolt/transport/base.rb +9 -4
- data/lib/bolt/transport/choria/agent_discovery.rb +32 -14
- data/lib/bolt/transport/choria/bolt_tasks.rb +64 -40
- data/lib/bolt/transport/choria/client.rb +66 -49
- data/lib/bolt/transport/choria/command_builders.rb +144 -24
- data/lib/bolt/transport/choria/file_transfer.rb +134 -0
- data/lib/bolt/transport/choria/helpers.rb +25 -20
- data/lib/bolt/transport/choria/shell.rb +255 -148
- data/lib/bolt/transport/choria.rb +96 -27
- data/lib/bolt/util.rb +13 -0
- data/lib/bolt/version.rb +1 -1
- data/lib/bolt_spec/plans/mock_executor.rb +4 -0
- data/libexec/apply_catalog.rb +3 -0
- 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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
|
|
103
|
+
script_content = File.binread(script)
|
|
66
104
|
|
|
67
105
|
begin
|
|
68
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
|
155
|
+
return unless targets.first.options['cleanup']
|
|
129
156
|
|
|
130
|
-
|
|
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
|
-
|
|
166
|
-
|
|
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
|
-
|
|
176
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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?
|
|
270
|
-
|
|
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
|
|
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,
|
|
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:
|
|
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,
|
|
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,
|
|
376
|
+
exitcode = exitcode_from(data[:exitcode], target, description)
|
|
300
377
|
next if exitcode.zero?
|
|
301
378
|
|
|
302
|
-
failures[target] = error_output(
|
|
303
|
-
|
|
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
|
-
#
|
|
314
|
-
#
|
|
315
|
-
#
|
|
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
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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,
|
|
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
|
-
|
|
367
|
-
|
|
449
|
+
finished_jobs = {}
|
|
368
450
|
poll_result = poll_with_retries(pending, timeout, 'shell.list') do |remaining|
|
|
369
|
-
|
|
370
|
-
next { rpc_failed: true
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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]
|
|
425
|
-
# -
|
|
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
|
-
|
|
434
|
-
|
|
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
|
-
|
|
439
|
-
|
|
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
|
-
|
|
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
|
-
|
|
528
|
+
finished[target] = remaining[target] if SHELL_DONE_STATUSES.include?(status)
|
|
463
529
|
end
|
|
464
530
|
|
|
465
|
-
[
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
555
|
-
|
|
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
|