openbolt 5.6.0 → 9.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/Puppetfile +35 -35
  3. data/bolt-modules/boltlib/lib/puppet/datatypes/result.rb +1 -1
  4. data/bolt-modules/boltlib/lib/puppet/datatypes/resultset.rb +2 -2
  5. data/bolt-modules/boltlib/lib/puppet/datatypes/target.rb +2 -2
  6. data/bolt-modules/boltlib/lib/puppet/functions/apply_prep.rb +2 -2
  7. data/bolt-modules/boltlib/lib/puppet/functions/background.rb +1 -1
  8. data/bolt-modules/boltlib/lib/puppet/functions/fail_plan.rb +1 -1
  9. data/bolt-modules/boltlib/lib/puppet/functions/get_resources.rb +1 -1
  10. data/bolt-modules/boltlib/lib/puppet/functions/puppetdb_command.rb +1 -1
  11. data/bolt-modules/boltlib/lib/puppet/functions/puppetdb_query.rb +1 -1
  12. data/bolt-modules/boltlib/lib/puppet/functions/run_plan.rb +27 -4
  13. data/bolt-modules/boltlib/lib/puppet/functions/run_task.rb +14 -22
  14. data/bolt-modules/boltlib/lib/puppet/functions/run_task_with.rb +14 -26
  15. data/bolt-modules/boltlib/lib/puppet/functions/wait.rb +2 -2
  16. data/bolt-modules/file/lib/puppet/functions/file/delete.rb +1 -1
  17. data/bolt-modules/file/lib/puppet/functions/file/exists.rb +1 -1
  18. data/bolt-modules/file/lib/puppet/functions/file/read.rb +1 -1
  19. data/bolt-modules/file/lib/puppet/functions/file/readable.rb +1 -1
  20. data/bolt-modules/file/lib/puppet/functions/file/write.rb +1 -1
  21. data/bolt-modules/log/lib/puppet/functions/log/debug.rb +1 -1
  22. data/bolt-modules/log/lib/puppet/functions/log/error.rb +1 -1
  23. data/bolt-modules/log/lib/puppet/functions/log/fatal.rb +1 -1
  24. data/bolt-modules/log/lib/puppet/functions/log/info.rb +1 -1
  25. data/bolt-modules/log/lib/puppet/functions/log/trace.rb +1 -1
  26. data/bolt-modules/log/lib/puppet/functions/log/warn.rb +1 -1
  27. data/guides/debugging.yaml +1 -1
  28. data/guides/inventory.yaml +2 -2
  29. data/guides/links.yaml +18 -9
  30. data/guides/logging.yaml +1 -1
  31. data/guides/module.yaml +1 -1
  32. data/guides/modulepath.yaml +1 -1
  33. data/guides/project.yaml +2 -2
  34. data/guides/targets.yaml +3 -1
  35. data/guides/transports.yaml +4 -3
  36. data/lib/bolt/analytics.rb +2 -10
  37. data/lib/bolt/bolt_option_parser.rb +66 -66
  38. data/lib/bolt/cli.rb +21 -2
  39. data/lib/bolt/config/options.rb +18 -18
  40. data/lib/bolt/config/transport/choria.rb +3 -0
  41. data/lib/bolt/config/transport/options.rb +34 -45
  42. data/lib/bolt/executor.rb +4 -0
  43. data/lib/bolt/fiber_executor.rb +46 -2
  44. data/lib/bolt/inventory/options.rb +1 -1
  45. data/lib/bolt/inventory/target.rb +1 -1
  46. data/lib/bolt/module_installer/puppetfile.rb +1 -1
  47. data/lib/bolt/module_installer/specs.rb +1 -1
  48. data/lib/bolt/outputter/human.rb +1 -2
  49. data/lib/bolt/plan_creator.rb +4 -4
  50. data/lib/bolt/plan_future.rb +5 -1
  51. data/lib/bolt/project_manager/inventory_migrator.rb +1 -1
  52. data/lib/bolt/project_manager/module_migrator.rb +1 -1
  53. data/lib/bolt/project_manager.rb +1 -1
  54. data/lib/bolt/shell/bash.rb +7 -4
  55. data/lib/bolt/shell/powershell/snippets.rb +20 -1
  56. data/lib/bolt/shell/powershell.rb +2 -2
  57. data/lib/bolt/transport/base.rb +9 -4
  58. data/lib/bolt/transport/choria/agent_discovery.rb +32 -14
  59. data/lib/bolt/transport/choria/bolt_tasks.rb +64 -40
  60. data/lib/bolt/transport/choria/client.rb +66 -49
  61. data/lib/bolt/transport/choria/command_builders.rb +144 -24
  62. data/lib/bolt/transport/choria/file_transfer.rb +134 -0
  63. data/lib/bolt/transport/choria/helpers.rb +25 -20
  64. data/lib/bolt/transport/choria/shell.rb +255 -148
  65. data/lib/bolt/transport/choria.rb +96 -27
  66. data/lib/bolt/transport/ssh/connection.rb +2 -1
  67. data/lib/bolt/util.rb +13 -0
  68. data/lib/bolt/version.rb +1 -1
  69. data/lib/bolt_spec/plans/mock_executor.rb +4 -0
  70. data/libexec/apply_catalog.rb +3 -0
  71. metadata +22 -7
@@ -15,13 +15,13 @@ module Bolt
15
15
  # @return [Array<Bolt::Result>] Results for all targets
16
16
  def run_task_via_bolt_tasks(targets, task, arguments, result_opts, &callback)
17
17
  logger.debug { "Running task #{task.name} via bolt_tasks agent on #{target_count(targets)}" }
18
- results = []
18
+ groups, results = group_by_implementation(targets, task, result_opts, &callback)
19
19
 
20
20
  # Start all implementation groups. Each gets its own download +
21
21
  # run_no_wait sequence. Tasks begin executing on nodes as soon as
22
22
  # run_no_wait returns.
23
23
  started_groups = []
24
- targets.group_by { |target| select_implementation(target, task) }.each do |implementation, impl_targets|
24
+ groups.each do |implementation, impl_targets|
25
25
  start_result = download_and_start_task(impl_targets, task, implementation,
26
26
  arguments, result_opts, &callback)
27
27
  results += start_result[:failed_results]
@@ -51,35 +51,35 @@ module Bolt
51
51
  # @return [Hash] with keys:
52
52
  # - :failed_results [Array<Bolt::Result>] Error results from setup phase
53
53
  # - :targets [Array<Bolt::Target>] Targets that successfully started
54
- # - :task_id [String, nil] Shared task ID for polling, nil if nothing started
54
+ # - :task_id [String] Shared task ID for polling
55
+ # When nothing started, only :failed_results is set.
55
56
  def download_and_start_task(targets, task, implementation, arguments, result_opts, &callback)
56
57
  environment = targets.first.options['puppet-environment']
57
58
  input_method = implementation['input_method']
58
59
  impl_files = [{ 'name' => File.basename(implementation['name']), 'path' => implementation['path'] }] +
59
- (implementation['files'] || [])
60
+ implementation['files']
60
61
  file_specs_json = impl_files.map { |file| task_file_spec(file, task.module_name, environment) }.to_json
61
62
 
62
63
  # The failed_results reference will get updated and if we ever end up without
63
64
  # any targets left to act on, we can return it immediately.
64
65
  failed_results = []
65
- none_started_result = { failed_results: failed_results, targets: [], task_id: nil }
66
+ none_started_result = { failed_results: failed_results }
66
67
 
67
68
  # Download task files
68
69
  logger.debug { "Downloading task #{task.name} files via bolt_tasks to #{target_count(targets)}" }
69
- response = rpc_request('bolt_tasks', targets, 'bolt_tasks.download') do |client|
70
+ response = rpc_request('bolt_tasks', targets, 'bolt_tasks.download', accept_aborted: true) do |client|
70
71
  client.download(task: task.name, files: file_specs_json, environment: environment)
71
72
  end
72
- # The bolt_tasks agent uses reply.fail! with statuscode 1 for download
73
- # failures, which rpc_request routes to :responded since statuscode 0-1
74
- # means the action completed. Check rpc_statuscodes to catch these and
75
- # report the download failure clearly instead of letting run_no_wait
76
- # fail with a confusing "task not available" error.
73
+ # A download that fails reaches the client as an RPC error with
74
+ # statuscode 5, since the agent's download raises after its retries,
75
+ # and rpc_request puts it under :errors. A statuscode 1 reply stays
76
+ # under :responded here, since the call accepts aborted replies, so it
77
+ # moves to the errors too, instead of letting run_no_wait fail with a
78
+ # confusing "task not available" error.
77
79
  dl_errors = response[:errors]
78
- response[:rpc_statuscodes].each do |target, code|
79
- next if code.zero? || dl_errors.key?(target)
80
-
80
+ response[:aborted].each do |target, statusmsg|
81
81
  dl_errors[target] = error_output(
82
- "bolt_tasks.download on #{target.safe_name} failed to download task files",
82
+ "bolt_tasks.download on #{target.safe_name} failed to download task files: #{statusmsg}",
83
83
  'bolt/choria-download-failed'
84
84
  )
85
85
  end
@@ -90,18 +90,29 @@ module Bolt
90
90
 
91
91
  # Start task execution
92
92
  logger.debug { "Starting task #{task.name} on #{target_count(remaining)}" }
93
- response = rpc_request('bolt_tasks', remaining, 'bolt_tasks.run_no_wait') do |client|
93
+ response = rpc_request('bolt_tasks', remaining, 'bolt_tasks.run_no_wait', accept_aborted: true) do |client|
94
94
  client.run_no_wait(task: task.name, input_method: input_method,
95
95
  files: file_specs_json, input: arguments.to_json)
96
96
  end
97
- failed_results.concat(emit_results(response[:errors], **result_opts, &callback))
98
- return none_started_result if response[:responded].empty?
97
+ # A reply with statuscode 1 lands in :responded like a download
98
+ # failure does, so it is moved to the errors the same way. Otherwise
99
+ # the target would be polled for a task that never ran, or its reply
100
+ # without a task_id would stand in for the whole group.
101
+ start_errors = response[:errors]
102
+ response[:aborted].each do |target, statusmsg|
103
+ start_errors[target] = error_output(
104
+ "bolt_tasks.run_no_wait on #{target.safe_name} failed to start the task: #{statusmsg}",
105
+ 'bolt/choria-task-start-failed'
106
+ )
107
+ end
108
+ failed_results.concat(emit_results(start_errors, **result_opts, &callback))
109
+ started = response[:responded].except(*start_errors.keys)
99
110
 
100
111
  # Extract the shared task_id (all targets get the same one from
101
112
  # the single run_no_wait call that fanned out to all of them)
102
- task_id = response[:responded].values.first&.dig(:task_id)
113
+ task_id = started.values.first&.dig(:task_id)
103
114
  unless task_id
104
- no_id_errors = response[:responded].each_with_object({}) do |(target, _), errors|
115
+ no_id_errors = started.each_with_object({}) do |(target, _), errors|
105
116
  errors[target] = error_output(
106
117
  "bolt_tasks.run_no_wait on #{target.safe_name} succeeded but returned no task_id",
107
118
  'bolt/choria-missing-task-id'
@@ -111,8 +122,8 @@ module Bolt
111
122
  return none_started_result
112
123
  end
113
124
 
114
- logger.debug { "Started task #{task.name} on #{target_count(response[:responded])}, task_id: #{task_id}" }
115
- { failed_results: failed_results, targets: response[:responded].keys, task_id: task_id }
125
+ logger.debug { "Started task #{task.name} on #{target_count(started)}, task_id: #{task_id}" }
126
+ { failed_results: failed_results, targets: started.keys, task_id: task_id }
116
127
  end
117
128
 
118
129
  # Poll bolt_tasks.task_status until all targets complete or timeout.
@@ -125,12 +136,14 @@ module Bolt
125
136
  timeout = targets.first.options['task-timeout']
126
137
 
127
138
  poll_result = poll_with_retries(targets, timeout, 'bolt_tasks.task_status') do |remaining|
128
- response = rpc_request('bolt_tasks', remaining, 'bolt_tasks.task_status') do |client|
139
+ # The agent answers statuscode 1 for a task that ran and failed,
140
+ # with the exit code and output in the data.
141
+ response = rpc_request('bolt_tasks', remaining, 'bolt_tasks.task_status', accept_aborted: true) do |client|
129
142
  client.task_status(task_id: task_id)
130
143
  end
131
- next { rpc_failed: true, done: {} } if response[:rpc_failed]
144
+ next { rpc_failed: true } if response[:rpc_failed]
132
145
 
133
- done = response[:errors].dup
146
+ done = response[:errors]
134
147
 
135
148
  response[:responded].each do |target, data|
136
149
  if data.nil?
@@ -149,14 +162,8 @@ module Bolt
149
162
  end
150
163
 
151
164
  remaining_errors = poll_result[:remaining].each_with_object({}) do |target, errors|
152
- errors[target] =
153
- if poll_result[:rpc_persistent_failure]
154
- error_output("RPC requests to poll task status on #{target.safe_name} failed persistently",
155
- 'bolt/choria-poll-failed')
156
- else
157
- error_output("Task #{task.name} timed out after #{timeout} seconds on #{target.safe_name}",
158
- 'bolt/choria-task-timeout')
159
- end
165
+ errors[target] = error_output("Task #{task.name} timed out after #{timeout} seconds on #{target.safe_name}",
166
+ 'bolt/choria-task-timeout')
160
167
  end
161
168
 
162
169
  poll_result[:completed].merge(remaining_errors)
@@ -168,13 +175,14 @@ module Bolt
168
175
  # @param target [Bolt::Target] Target for logging and stdout unwrapping context
169
176
  # @return [Hash] Output hash from output() or error_output()
170
177
  def extract_task_output(data, target)
171
- exitcode = exitcode_from(data, target, 'task')
178
+ exitcode = exitcode_from(data[:exitcode], target, 'task')
172
179
  output(stdout: unwrap_bolt_tasks_stdout(data[:stdout]),
173
180
  stderr: data[:stderr], exitcode: exitcode)
174
181
  end
175
182
 
176
183
  # Build a file spec hash for the bolt_tasks download action. Computes
177
- # the Puppet Server file_content URI based on the file's module-relative path.
184
+ # the server's file_content URI from the file's module-relative path,
185
+ # the way the server does it for the task metadata it serves.
178
186
  #
179
187
  # @param file [Hash] With 'name' (module-relative path) and 'path' (local absolute path)
180
188
  # @param module_name [String] Task's module name (used for simple task files)
@@ -192,7 +200,10 @@ module Bolt
192
200
  when 'files'
193
201
  "/puppet/v3/file_content/modules/#{mod}/#{rest}"
194
202
  when 'lib'
195
- "/puppet/v3/file_content/plugins/#{mod}/#{rest}"
203
+ # The plugins mount serves the lib directories of every module as one tree
204
+ "/puppet/v3/file_content/plugins/#{rest}"
205
+ when 'scripts'
206
+ "/puppet/v3/file_content/scripts/#{mod}/#{rest}"
196
207
  else
197
208
  "/puppet/v3/file_content/tasks/#{mod}/#{rest}"
198
209
  end
@@ -211,7 +222,8 @@ module Bolt
211
222
  }
212
223
  end
213
224
 
214
- # Fix double-encoding in the bolt_tasks agent's wrapper error path.
225
+ # Turn the stdout of a bolt_tasks task_status reply into what the task
226
+ # printed, for Result.for_task.
215
227
  #
216
228
  # Normally, create_task_stdout returns a Hash and reply_task_status
217
229
  # calls .to_json on it, producing a single JSON string like:
@@ -228,8 +240,13 @@ module Bolt
228
240
  # case, it produces a String (the inner JSON), which we return so
229
241
  # Result.for_task can parse it.
230
242
  #
243
+ # For a task that exits non-zero without an _error of its own,
244
+ # create_task_stdout adds one of kind choria.tasks/task-error. We take
245
+ # it out and return what the task printed, so Result.for_task reports
246
+ # the failure the way it does for the other transports.
247
+ #
231
248
  # @param agent_stdout [String, nil] JSON-encoded stdout from the bolt_tasks agent
232
- # @return [String, nil] JSON string suitable for Result.for_task
249
+ # @return [String, nil] The text or JSON the task printed, for Result.for_task
233
250
  def unwrap_bolt_tasks_stdout(agent_stdout)
234
251
  return agent_stdout unless agent_stdout.is_a?(String)
235
252
 
@@ -239,9 +256,16 @@ module Bolt
239
256
  return agent_stdout
240
257
  end
241
258
 
242
- # Normal case: parsed is a Hash, return the original JSON string.
243
259
  # Double-encoded case: parsed is a String (the inner JSON), return it.
244
- parsed.is_a?(String) ? parsed : agent_stdout
260
+ return parsed if parsed.is_a?(String)
261
+
262
+ # A Hash without the agent's error is the normal case and returns the original JSON string
263
+ agent_error = parsed.is_a?(Hash) && parsed['_error'].is_a?(Hash) && parsed['_error']['kind'] == 'choria.tasks/task-error'
264
+ return agent_stdout unless agent_error
265
+
266
+ # The agent puts output that isn't a JSON object under _output
267
+ printed = parsed.except('_error')
268
+ printed.keys == ['_output'] && printed['_output'].is_a?(String) ? printed['_output'] : printed.to_json
245
269
  end
246
270
  end
247
271
  end
@@ -79,7 +79,7 @@ module Bolt
79
79
  logger.debug { "Choria brokers overridden: #{brokers.join(', ')}" }
80
80
  end
81
81
 
82
- if opts['ssl-ca'] && opts['ssl-cert'] && opts['ssl-key']
82
+ if opts['ssl-ca']
83
83
  unreadable = %w[ssl-ca ssl-cert ssl-key].find { |key| !File.readable?(opts[key]) }
84
84
  if unreadable
85
85
  raise Bolt::Error.new(
@@ -101,9 +101,9 @@ module Bolt
101
101
  end
102
102
 
103
103
  # Create an MCollective::RPC::Client for one or more targets.
104
- # Accepts a single target or an array. Uses MCollective's direct
105
- # addressing mode (client.discover(nodes:)) to skip broadcast
106
- # discovery and send requests directly to the specified nodes.
104
+ # Uses MCollective's direct addressing mode (client.discover(nodes:))
105
+ # to skip broadcast discovery and send requests directly to the
106
+ # specified nodes.
107
107
  #
108
108
  # Note that when the client is created, if the shell agent isn't already
109
109
  # installed on the OpenBolt controller node, then the shell DDL that we
@@ -113,24 +113,16 @@ module Bolt
113
113
  # DDL is already included in the choria-mcorpc-support gem.
114
114
  #
115
115
  # @param agent_name [String] MCollective agent name (e.g. 'shell', 'bolt_tasks')
116
- # @param targets [Bolt::Target, Array<Bolt::Target>] One or more targets to address
116
+ # @param targets [Array<Bolt::Target>] Targets to address
117
117
  # @param timeout [Numeric] RPC call timeout in seconds
118
118
  # @return [MCollective::RPC::Client] Configured client with direct addressing enabled
119
119
  def create_rpc_client(agent_name, targets, timeout)
120
- targets = Array(targets)
121
- options = MCollective::Util.default_options
122
- options[:timeout] = timeout
123
- options[:verbose] = false
124
- options[:connection_timeout] = targets.first.options['broker-timeout']
120
+ client = MCollective::RPC::Client.new(agent_name, options: client_options(targets))
121
+ # Set after the build, which replaces a timeout of exactly 5, the
122
+ # gem's default, with the DDL timeout plus the discovery timeout.
123
+ client.timeout = timeout
125
124
 
126
- collective = collective_for(targets.first)
127
- options[:collective] = collective if collective
128
-
129
- client = MCollective::RPC::Client.new(agent_name, options: options)
130
- client.progress = false
131
-
132
- identities = targets.map { |target| choria_identity(target) }.uniq
133
- client.discover(nodes: identities)
125
+ client.discover(nodes: identities_of(targets))
134
126
 
135
127
  client
136
128
  end
@@ -140,13 +132,14 @@ module Bolt
140
132
  #
141
133
  # Results are split based on MCollective RPC statuscodes:
142
134
  # - statuscode 0: action completed successfully (:responded)
143
- # - statuscode 1 (RPCAborted): action completed but reported a
144
- # problem (:responded). The data is preserved rather than
145
- # discarded because some agents (notably bolt_tasks) use
146
- # statuscode 1 for application-level failures where the
147
- # response data is still valid and meaningful (e.g., a task
148
- # that ran but exited non-zero). Callers must handle this
149
- # case and not assume :responded means success.
135
+ # - statuscode 1 (RPCAborted): the action could not run or reported a
136
+ # problem. An error (:errors) by default, since the shell and rpcutil
137
+ # agents never answer 1 with data the transport can use, and a denied
138
+ # action policy answers 1 as well. With accept_aborted set the reply
139
+ # stays under :responded with its data and its status message goes
140
+ # under :aborted, which bolt_tasks needs, since it answers 1 for a
141
+ # task that ran and failed with the data still valid. Such a caller
142
+ # must not assume :responded means success.
150
143
  # - statuscode 2-5: RPC infrastructure error (:errors)
151
144
  # - no response: target didn't reply (:errors)
152
145
  # - exception: total RPC failure (rpc_failed: true)
@@ -159,27 +152,27 @@ module Bolt
159
152
  # @param agent [String] MCollective agent name (e.g. 'shell', 'bolt_tasks', 'rpcutil')
160
153
  # @param targets [Bolt::Target, Array<Bolt::Target>] One or more targets to address
161
154
  # @param context [String] Human-readable label for logging (e.g. 'shell.start')
155
+ # @param accept_aborted [Boolean] Whether a statuscode 1 reply counts as a response, default is false
162
156
  # @yield [MCollective::RPC::Client] The configured RPC client to invoke an action on
163
157
  # @return [Hash] with keys:
164
- # - :responded [Hash] Targets where the action completed (statuscode 0-1),
165
- # mapped to their response data
158
+ # - :responded [Hash] Targets where the action completed (statuscode 0, or 1 with
159
+ # accept_aborted), mapped to their response data
166
160
  # - :errors [Hash] Targets with RPC errors or no response, mapped to error output hashes
167
161
  # - :rpc_failed [Boolean] True when the entire RPC call failed
168
- # - :rpc_statuscodes [Hash] Per-target MCollective RPC statuscodes.
169
- # Includes all targets that responded (both :responded and :errors).
170
- # Not populated when rpc_failed is true (no individual responses).
171
- def rpc_request(agent, targets, context)
162
+ # - :aborted [Hash] Targets in :responded whose reply had statuscode 1,
163
+ # mapped to the agent's status message, so a caller can report the
164
+ # agent's reason
165
+ def rpc_request(agent, targets, context, accept_aborted: false)
172
166
  targets = Array(targets)
173
167
  rpc_results = @rpc_mutex.synchronize do
174
- rpc_timeout = targets.first.options['rpc-timeout']
175
- client = create_rpc_client(agent, targets, rpc_timeout)
168
+ client = create_rpc_client(agent, targets, targets.first.options['rpc-timeout'])
176
169
  yield(client)
177
170
  end
178
171
  by_sender = index_results_by_sender(rpc_results, targets, context)
179
172
 
180
173
  responded = {}
181
174
  errors = {}
182
- rpc_statuscodes = {}
175
+ aborted = {}
183
176
  targets.each do |target|
184
177
  rpc_result = by_sender[choria_identity(target)]
185
178
  if rpc_result.nil?
@@ -187,22 +180,21 @@ module Bolt
187
180
  "No response from #{target.safe_name} for #{context}",
188
181
  'bolt/choria-no-response'
189
182
  )
190
- elsif rpc_result[:statuscode] > 1
191
- rpc_statuscodes[target] = rpc_result[:statuscode]
183
+ elsif rpc_result[:statuscode] > 1 || (rpc_result[:statuscode] == 1 && !accept_aborted)
192
184
  errors[target] = error_output(
193
185
  "#{context} on #{target.safe_name} returned RPC error: " \
194
186
  "#{rpc_result[:statusmsg]} (code #{rpc_result[:statuscode]})",
195
187
  'bolt/choria-rpc-error'
196
188
  )
197
189
  else
198
- rpc_statuscodes[target] = rpc_result[:statuscode]
199
190
  if rpc_result[:statuscode] == 1
200
191
  logger.warn { "#{context} on #{target.safe_name} had RPC status code #{rpc_result[:statuscode]}: #{rpc_result[:statusmsg]}" }
192
+ aborted[target] = rpc_result[:statusmsg]
201
193
  end
202
194
  responded[target] = rpc_result[:data]
203
195
  end
204
196
  end
205
- { responded: responded, errors: errors, rpc_failed: false, rpc_statuscodes: rpc_statuscodes }
197
+ { responded: responded, errors: errors, rpc_failed: false, aborted: aborted }
206
198
  rescue StandardError => e
207
199
  raise if e.is_a?(Bolt::Error)
208
200
 
@@ -211,11 +203,26 @@ module Bolt
211
203
  errs[target] = error_output("#{context} failed on #{target.safe_name}: #{e.message}",
212
204
  'bolt/choria-rpc-failed')
213
205
  end
214
- { responded: {}, errors: errors, rpc_failed: true, rpc_statuscodes: {} }
206
+ { responded: {}, errors: errors, rpc_failed: true, aborted: {} }
207
+ end
208
+
209
+ # The client options the targets' configuration gives: the broker
210
+ # timeout and the collective over the gem's defaults. The file
211
+ # transfer library builds its own clients from the same hash, under
212
+ # the same RPC mutex.
213
+ #
214
+ # @param targets [Array<Bolt::Target>] The targets of one batch
215
+ # @return [Hash]
216
+ def client_options(targets)
217
+ options = MCollective::Util.default_options
218
+ options[:connection_timeout] = targets.first.options['broker-timeout']
219
+ options[:collective] = collective_for(targets.first)
220
+ options
215
221
  end
216
222
 
217
- # Configure the client, discover agents, partition targets by agent
218
- # availability, and emit errors for incapable targets.
223
+ # Fire node_start for every target, configure the client, discover
224
+ # agents, partition targets by agent availability, and emit errors for
225
+ # incapable targets.
219
226
  #
220
227
  # @param targets [Array<Bolt::Target>] Targets to prepare
221
228
  # @param agent_name [String] Required agent name (e.g. 'shell', 'bolt_tasks')
@@ -225,24 +232,34 @@ module Bolt
225
232
  # - [Array<Bolt::Target>] Targets that have the required agent
226
233
  # - [Array<Bolt::Result>] Error results for targets that lack the agent
227
234
  def prepare_targets(targets, agent_name, result_opts, &callback)
235
+ targets.each { |target| callback&.call(type: :node_start, target: target) }
228
236
  configure_client(targets.first)
229
- discover_agents(targets)
237
+ discovery_failures = discover_agents(targets)
230
238
 
231
239
  capable, incapable = targets.partition { |target| has_agent?(target, agent_name) }
232
240
 
233
241
  agent_errors = incapable.each_with_object({}) do |target, errors|
234
- msg = if @agent_cache[choria_identity(target)].nil?
235
- "No agent information available for #{target.safe_name} (node did not respond to discovery)"
236
- else
237
- "The '#{agent_name}' agent is not available on #{target.safe_name}."
238
- end
239
- errors[target] = error_output(msg, 'bolt/choria-agent-not-available')
242
+ errors[target] = if discovered?(target)
243
+ error_output("The '#{agent_name}' agent is not available on #{target.safe_name}.", 'bolt/choria-agent-not-available')
244
+ else
245
+ discovery_failures[target] || undiscovered_error(target)
246
+ end
240
247
  end
241
- incapable_results = emit_results(agent_errors, fire_node_start: true, **result_opts, &callback)
248
+ incapable_results = emit_results(agent_errors, **result_opts, &callback)
242
249
 
243
250
  [capable, incapable_results]
244
251
  end
245
252
 
253
+ # The error output for a target that discovery has no agent list and no
254
+ # failure for, which happens when discovery raised before it finished.
255
+ #
256
+ # @param target [Bolt::Target]
257
+ # @return [Hash] An error output hash
258
+ def undiscovered_error(target)
259
+ error_output("No agent information available for #{target.safe_name} (agent discovery failed)",
260
+ 'bolt/choria-agent-not-available')
261
+ end
262
+
246
263
  # Index RPC results by sender, keeping only the first response per
247
264
  # sender and only from the set of expected identities. Logs and discards
248
265
  # responses from unexpected senders and duplicates.