openbolt 5.3.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 +6 -7
- 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 +35 -14
- data/lib/bolt/plugin/puppet_connect_data.rb +0 -85
- data/modules/puppet_connect/plans/test_input_data.pp +0 -94
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bolt
|
|
4
|
+
module Transport
|
|
5
|
+
class Choria
|
|
6
|
+
# Run a task via the bolt_tasks agent. Groups targets by implementation
|
|
7
|
+
# to support mixed-platform batches. Starts all groups before polling any
|
|
8
|
+
# of them so tasks execute concurrently on nodes across implementations.
|
|
9
|
+
#
|
|
10
|
+
# @param targets [Array<Bolt::Target>] Targets that have the bolt_tasks agent
|
|
11
|
+
# @param task [Bolt::Task] Task to execute
|
|
12
|
+
# @param arguments [Hash] Task parameter names to values
|
|
13
|
+
# @param result_opts [Hash] Options passed through to emit_results (:action, :name, :position)
|
|
14
|
+
# @param callback [Proc] Called with :node_start and :node_result events
|
|
15
|
+
# @return [Array<Bolt::Result>] Results for all targets
|
|
16
|
+
def run_task_via_bolt_tasks(targets, task, arguments, result_opts, &callback)
|
|
17
|
+
logger.debug { "Running task #{task.name} via bolt_tasks agent on #{target_count(targets)}" }
|
|
18
|
+
results = []
|
|
19
|
+
|
|
20
|
+
# Start all implementation groups. Each gets its own download +
|
|
21
|
+
# run_no_wait sequence. Tasks begin executing on nodes as soon as
|
|
22
|
+
# run_no_wait returns.
|
|
23
|
+
started_groups = []
|
|
24
|
+
targets.group_by { |target| select_implementation(target, task) }.each do |implementation, impl_targets|
|
|
25
|
+
start_result = download_and_start_task(impl_targets, task, implementation,
|
|
26
|
+
arguments, result_opts, &callback)
|
|
27
|
+
results += start_result[:failed_results]
|
|
28
|
+
started_groups << start_result if start_result[:task_id]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Poll each group. Tasks are already running concurrently on nodes,
|
|
32
|
+
# so wall time is dominated by the longest task, not the sum.
|
|
33
|
+
# Each group has a different task_id, so they must be polled separately.
|
|
34
|
+
started_groups.each do |group|
|
|
35
|
+
output_by_target = poll_task_status(group[:targets], group[:task_id], task)
|
|
36
|
+
results += emit_results(output_by_target, **result_opts, &callback)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
results
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Download task files from the server and start execution for one
|
|
43
|
+
# implementation group via bolt_tasks.download and bolt_tasks.run_no_wait.
|
|
44
|
+
#
|
|
45
|
+
# @param targets [Array<Bolt::Target>] Targets sharing the same implementation
|
|
46
|
+
# @param task [Bolt::Task] Task being executed
|
|
47
|
+
# @param implementation [Hash] Task implementation with 'path', 'name', 'input_method', 'files' keys
|
|
48
|
+
# @param arguments [Hash] Task parameter names to values
|
|
49
|
+
# @param result_opts [Hash] Options passed through to emit_results (:action, :name, :position)
|
|
50
|
+
# @param callback [Proc] Called with :node_start and :node_result events
|
|
51
|
+
# @return [Hash] with keys:
|
|
52
|
+
# - :failed_results [Array<Bolt::Result>] Error results from setup phase
|
|
53
|
+
# - :targets [Array<Bolt::Target>] Targets that successfully started
|
|
54
|
+
# - :task_id [String, nil] Shared task ID for polling, nil if nothing started
|
|
55
|
+
def download_and_start_task(targets, task, implementation, arguments, result_opts, &callback)
|
|
56
|
+
environment = targets.first.options['puppet-environment']
|
|
57
|
+
input_method = implementation['input_method']
|
|
58
|
+
impl_files = [{ 'name' => File.basename(implementation['name']), 'path' => implementation['path'] }] +
|
|
59
|
+
(implementation['files'] || [])
|
|
60
|
+
file_specs_json = impl_files.map { |file| task_file_spec(file, task.module_name, environment) }.to_json
|
|
61
|
+
|
|
62
|
+
# The failed_results reference will get updated and if we ever end up without
|
|
63
|
+
# any targets left to act on, we can return it immediately.
|
|
64
|
+
failed_results = []
|
|
65
|
+
none_started_result = { failed_results: failed_results, targets: [], task_id: nil }
|
|
66
|
+
|
|
67
|
+
# Download task files
|
|
68
|
+
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
|
+
client.download(task: task.name, files: file_specs_json, environment: environment)
|
|
71
|
+
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.
|
|
77
|
+
dl_errors = response[:errors]
|
|
78
|
+
response[:rpc_statuscodes].each do |target, code|
|
|
79
|
+
next if code.zero? || dl_errors.key?(target)
|
|
80
|
+
|
|
81
|
+
dl_errors[target] = error_output(
|
|
82
|
+
"bolt_tasks.download on #{target.safe_name} failed to download task files",
|
|
83
|
+
'bolt/choria-download-failed'
|
|
84
|
+
)
|
|
85
|
+
end
|
|
86
|
+
# Must use concat rather than += to preserve reference to failed_results for early return
|
|
87
|
+
failed_results.concat(emit_results(dl_errors, **result_opts, &callback))
|
|
88
|
+
remaining = response[:responded].keys - dl_errors.keys
|
|
89
|
+
return none_started_result if remaining.empty?
|
|
90
|
+
|
|
91
|
+
# Start task execution
|
|
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|
|
|
94
|
+
client.run_no_wait(task: task.name, input_method: input_method,
|
|
95
|
+
files: file_specs_json, input: arguments.to_json)
|
|
96
|
+
end
|
|
97
|
+
failed_results.concat(emit_results(response[:errors], **result_opts, &callback))
|
|
98
|
+
return none_started_result if response[:responded].empty?
|
|
99
|
+
|
|
100
|
+
# Extract the shared task_id (all targets get the same one from
|
|
101
|
+
# the single run_no_wait call that fanned out to all of them)
|
|
102
|
+
task_id = response[:responded].values.first&.dig(:task_id)
|
|
103
|
+
unless task_id
|
|
104
|
+
no_id_errors = response[:responded].each_with_object({}) do |(target, _), errors|
|
|
105
|
+
errors[target] = error_output(
|
|
106
|
+
"bolt_tasks.run_no_wait on #{target.safe_name} succeeded but returned no task_id",
|
|
107
|
+
'bolt/choria-missing-task-id'
|
|
108
|
+
)
|
|
109
|
+
end
|
|
110
|
+
failed_results.concat(emit_results(no_id_errors, **result_opts, &callback))
|
|
111
|
+
return none_started_result
|
|
112
|
+
end
|
|
113
|
+
|
|
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 }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Poll bolt_tasks.task_status until all targets complete or timeout.
|
|
119
|
+
#
|
|
120
|
+
# @param targets [Array<Bolt::Target>] Targets that were started successfully
|
|
121
|
+
# @param task_id [String] Shared task ID from run_no_wait
|
|
122
|
+
# @param task [Bolt::Task] Task being polled (used for timeout and error messages)
|
|
123
|
+
# @return [Hash{Bolt::Target => Hash}] Output hash for every target (success and error)
|
|
124
|
+
def poll_task_status(targets, task_id, task)
|
|
125
|
+
timeout = targets.first.options['task-timeout']
|
|
126
|
+
|
|
127
|
+
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|
|
|
129
|
+
client.task_status(task_id: task_id)
|
|
130
|
+
end
|
|
131
|
+
next { rpc_failed: true, done: {} } if response[:rpc_failed]
|
|
132
|
+
|
|
133
|
+
done = response[:errors].dup
|
|
134
|
+
|
|
135
|
+
response[:responded].each do |target, data|
|
|
136
|
+
if data.nil?
|
|
137
|
+
done[target] = error_output(
|
|
138
|
+
"bolt_tasks.task_status on #{target.safe_name} returned success but no data",
|
|
139
|
+
'bolt/choria-missing-data'
|
|
140
|
+
)
|
|
141
|
+
next
|
|
142
|
+
end
|
|
143
|
+
next unless data[:completed]
|
|
144
|
+
|
|
145
|
+
done[target] = extract_task_output(data, target)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
{ rpc_failed: false, done: done }
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
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
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
poll_result[:completed].merge(remaining_errors)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Extract stdout, stderr, and exitcode from a bolt_tasks task_status response.
|
|
166
|
+
#
|
|
167
|
+
# @param data [Hash] Task_status response data with :stdout, :stderr, :exitcode keys
|
|
168
|
+
# @param target [Bolt::Target] Target for logging and stdout unwrapping context
|
|
169
|
+
# @return [Hash] Output hash from output() or error_output()
|
|
170
|
+
def extract_task_output(data, target)
|
|
171
|
+
exitcode = exitcode_from(data, target, 'task')
|
|
172
|
+
output(stdout: unwrap_bolt_tasks_stdout(data[:stdout]),
|
|
173
|
+
stderr: data[:stderr], exitcode: exitcode)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# 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.
|
|
178
|
+
#
|
|
179
|
+
# @param file [Hash] With 'name' (module-relative path) and 'path' (local absolute path)
|
|
180
|
+
# @param module_name [String] Task's module name (used for simple task files)
|
|
181
|
+
# @param environment [String] Puppet environment name for the URI params
|
|
182
|
+
# @return [Hash] File spec with 'filename', 'sha256', 'size_bytes', and 'uri' keys
|
|
183
|
+
def task_file_spec(file, module_name, environment)
|
|
184
|
+
file_name = file['name']
|
|
185
|
+
validate_file_name!(file_name)
|
|
186
|
+
file_path = file['path']
|
|
187
|
+
|
|
188
|
+
parts = file_name.split('/', 3)
|
|
189
|
+
path = if parts.length == 3
|
|
190
|
+
mod, subdir, rest = parts
|
|
191
|
+
case subdir
|
|
192
|
+
when 'files'
|
|
193
|
+
"/puppet/v3/file_content/modules/#{mod}/#{rest}"
|
|
194
|
+
when 'lib'
|
|
195
|
+
"/puppet/v3/file_content/plugins/#{mod}/#{rest}"
|
|
196
|
+
else
|
|
197
|
+
"/puppet/v3/file_content/tasks/#{mod}/#{rest}"
|
|
198
|
+
end
|
|
199
|
+
else
|
|
200
|
+
"/puppet/v3/file_content/tasks/#{module_name}/#{file_name}"
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
{
|
|
204
|
+
'filename' => file_name,
|
|
205
|
+
'sha256' => Digest::SHA256.file(file_path).hexdigest,
|
|
206
|
+
'size_bytes' => File.size(file_path),
|
|
207
|
+
'uri' => {
|
|
208
|
+
'path' => path,
|
|
209
|
+
'params' => { 'environment' => environment }
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Fix double-encoding in the bolt_tasks agent's wrapper error path.
|
|
215
|
+
#
|
|
216
|
+
# Normally, create_task_stdout returns a Hash and reply_task_status
|
|
217
|
+
# calls .to_json on it, producing a single JSON string like:
|
|
218
|
+
# '{"_output":"hello world"}'
|
|
219
|
+
#
|
|
220
|
+
# But for wrapper errors, create_task_stdout returns an already
|
|
221
|
+
# JSON-encoded String. reply_task_status still calls .to_json on
|
|
222
|
+
# it, encoding it a second time. The result is a JSON string whose
|
|
223
|
+
# value is itself a JSON string:
|
|
224
|
+
# '"{\\"_error\\":{\\"kind\\":\\"choria.tasks/wrapper-error\\",...}}"'
|
|
225
|
+
#
|
|
226
|
+
# We parse one layer of JSON. In the normal case, that produces a
|
|
227
|
+
# Hash and we return the original string. In the double-encoded
|
|
228
|
+
# case, it produces a String (the inner JSON), which we return so
|
|
229
|
+
# Result.for_task can parse it.
|
|
230
|
+
#
|
|
231
|
+
# @param agent_stdout [String, nil] JSON-encoded stdout from the bolt_tasks agent
|
|
232
|
+
# @return [String, nil] JSON string suitable for Result.for_task
|
|
233
|
+
def unwrap_bolt_tasks_stdout(agent_stdout)
|
|
234
|
+
return agent_stdout unless agent_stdout.is_a?(String)
|
|
235
|
+
|
|
236
|
+
parsed = begin
|
|
237
|
+
JSON.parse(agent_stdout)
|
|
238
|
+
rescue JSON::ParserError
|
|
239
|
+
return agent_stdout
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Normal case: parsed is a Hash, return the original JSON string.
|
|
243
|
+
# Double-encoded case: parsed is a String (the inner JSON), return it.
|
|
244
|
+
parsed.is_a?(String) ? parsed : agent_stdout
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bolt
|
|
4
|
+
module Transport
|
|
5
|
+
class Choria
|
|
6
|
+
# Number of consecutive RPC poll failures before giving up and marking
|
|
7
|
+
# all remaining targets as failed. Used by both polling loops
|
|
8
|
+
# (poll_task_status and wait_for_shell_results).
|
|
9
|
+
RPC_FAILURE_RETRIES = 3
|
|
10
|
+
|
|
11
|
+
# One-time setup of the local MCollective client connection to the
|
|
12
|
+
# NATS broker. MCollective::Config.loadconfig must only be called
|
|
13
|
+
# once since it loads plugins via PluginManager.loadclass, and a
|
|
14
|
+
# second call raises "Plugin already loaded".
|
|
15
|
+
#
|
|
16
|
+
# The @client_configured flag is checked twice: once before taking
|
|
17
|
+
# the mutex (fast path to avoid lock overhead on every call after
|
|
18
|
+
# setup) and once inside (handles the race where two batch threads
|
|
19
|
+
# both see false simultaneously and try to configure concurrently).
|
|
20
|
+
#
|
|
21
|
+
# This function is idempotent, so it should be called before any
|
|
22
|
+
# operation that needs the client connection to ensure it is configured
|
|
23
|
+
# correctly.
|
|
24
|
+
#
|
|
25
|
+
# @param target [Bolt::Target] Any target in the batch (used to read transport options)
|
|
26
|
+
def configure_client(target)
|
|
27
|
+
return if @client_configured
|
|
28
|
+
|
|
29
|
+
@config_mutex.synchronize do
|
|
30
|
+
return if @client_configured
|
|
31
|
+
# If a previous attempt failed after partially initializing
|
|
32
|
+
# MCollective (e.g., plugins loaded but NATS connector failed),
|
|
33
|
+
# retrying loadconfig would hit "Plugin already loaded" errors.
|
|
34
|
+
# Re-raise the original error so the caller gets a clear message.
|
|
35
|
+
raise @config_error if @config_error
|
|
36
|
+
|
|
37
|
+
# We do the require here because this is a pretty meaty library, and
|
|
38
|
+
# no need to load it when OpenBolt starts up if the user isn't using
|
|
39
|
+
# the Choria transport.
|
|
40
|
+
require 'mcollective'
|
|
41
|
+
|
|
42
|
+
opts = target.options
|
|
43
|
+
config = MCollective::Config.instance
|
|
44
|
+
|
|
45
|
+
unless config.configured
|
|
46
|
+
config_file = opts['config-file'] || MCollective::Util.config_file_for_user
|
|
47
|
+
|
|
48
|
+
unless File.readable?(config_file)
|
|
49
|
+
msg = if opts['config-file']
|
|
50
|
+
"Choria config file not found or not readable: #{config_file}"
|
|
51
|
+
else
|
|
52
|
+
"Could not find a readable Choria client config file. " \
|
|
53
|
+
"Searched: #{MCollective::Util.config_paths_for_user.join(', ')}. " \
|
|
54
|
+
"Set the 'config-file' option in the Choria transport configuration."
|
|
55
|
+
end
|
|
56
|
+
raise Bolt::Error.new(msg, 'bolt/choria-config-not-found')
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
begin
|
|
60
|
+
config.loadconfig(config_file)
|
|
61
|
+
rescue StandardError => e
|
|
62
|
+
@config_error = Bolt::Error.new(
|
|
63
|
+
"Choria client configuration failed: #{e.class}: #{e.message}",
|
|
64
|
+
'bolt/choria-config-failed'
|
|
65
|
+
)
|
|
66
|
+
raise @config_error
|
|
67
|
+
end
|
|
68
|
+
logger.debug { "Loaded Choria client config from #{config_file}" }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
if opts['mcollective-certname']
|
|
72
|
+
ENV['MCOLLECTIVE_CERTNAME'] = opts['mcollective-certname']
|
|
73
|
+
logger.debug { "MCOLLECTIVE_CERTNAME set to #{opts['mcollective-certname']}" }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
if opts['brokers']
|
|
77
|
+
brokers = Array(opts['brokers']).map { |broker| broker.include?(':') ? broker : "#{broker}:4222" }
|
|
78
|
+
config.pluginconf['choria.middleware_hosts'] = brokers.join(',')
|
|
79
|
+
logger.debug { "Choria brokers overridden: #{brokers.join(', ')}" }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
if opts['ssl-ca'] && opts['ssl-cert'] && opts['ssl-key']
|
|
83
|
+
unreadable = %w[ssl-ca ssl-cert ssl-key].find { |key| !File.readable?(opts[key]) }
|
|
84
|
+
if unreadable
|
|
85
|
+
raise Bolt::Error.new(
|
|
86
|
+
"File for #{unreadable} is not readable: #{opts[unreadable]}",
|
|
87
|
+
'bolt/choria-config-failed'
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
config.pluginconf['security.provider'] = 'file'
|
|
92
|
+
config.pluginconf['security.file.ca'] = opts['ssl-ca']
|
|
93
|
+
config.pluginconf['security.file.certificate'] = opts['ssl-cert']
|
|
94
|
+
config.pluginconf['security.file.key'] = opts['ssl-key']
|
|
95
|
+
logger.debug { "Using file-based TLS security provider with given SSL override(s)" }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
@default_collective = config.main_collective
|
|
99
|
+
@client_configured = true
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
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.
|
|
107
|
+
#
|
|
108
|
+
# Note that when the client is created, if the shell agent isn't already
|
|
109
|
+
# installed on the OpenBolt controller node, then the shell DDL that we
|
|
110
|
+
# bundle with OpenBolt at lib/mcollective/agent/shell.ddl
|
|
111
|
+
# automatically gets loaded since it's on the $LOAD_PATH and in the
|
|
112
|
+
# right place for MCollective's plugin loading. The bolt_tasks
|
|
113
|
+
# DDL is already included in the choria-mcorpc-support gem.
|
|
114
|
+
#
|
|
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
|
|
117
|
+
# @param timeout [Numeric] RPC call timeout in seconds
|
|
118
|
+
# @return [MCollective::RPC::Client] Configured client with direct addressing enabled
|
|
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']
|
|
125
|
+
|
|
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)
|
|
134
|
+
|
|
135
|
+
client
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Make a batched RPC call and split results into responded and errors.
|
|
139
|
+
# Yields the RPC client so the caller specifies which action to invoke.
|
|
140
|
+
#
|
|
141
|
+
# Results are split based on MCollective RPC statuscodes:
|
|
142
|
+
# - 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.
|
|
150
|
+
# - statuscode 2-5: RPC infrastructure error (:errors)
|
|
151
|
+
# - no response: target didn't reply (:errors)
|
|
152
|
+
# - exception: total RPC failure (rpc_failed: true)
|
|
153
|
+
#
|
|
154
|
+
# Serialized by @rpc_mutex because MCollective's NATS connector is a
|
|
155
|
+
# singleton with a shared receive queue. Concurrent RPC calls cause
|
|
156
|
+
# reply channel collisions, cross-thread message confusion, and subscription
|
|
157
|
+
# conflicts. See choria-transport-dev.md for the full explanation.
|
|
158
|
+
#
|
|
159
|
+
# @param agent [String] MCollective agent name (e.g. 'shell', 'bolt_tasks', 'rpcutil')
|
|
160
|
+
# @param targets [Bolt::Target, Array<Bolt::Target>] One or more targets to address
|
|
161
|
+
# @param context [String] Human-readable label for logging (e.g. 'shell.start')
|
|
162
|
+
# @yield [MCollective::RPC::Client] The configured RPC client to invoke an action on
|
|
163
|
+
# @return [Hash] with keys:
|
|
164
|
+
# - :responded [Hash] Targets where the action completed (statuscode 0-1),
|
|
165
|
+
# mapped to their response data
|
|
166
|
+
# - :errors [Hash] Targets with RPC errors or no response, mapped to error output hashes
|
|
167
|
+
# - :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)
|
|
172
|
+
targets = Array(targets)
|
|
173
|
+
rpc_results = @rpc_mutex.synchronize do
|
|
174
|
+
rpc_timeout = targets.first.options['rpc-timeout']
|
|
175
|
+
client = create_rpc_client(agent, targets, rpc_timeout)
|
|
176
|
+
yield(client)
|
|
177
|
+
end
|
|
178
|
+
by_sender = index_results_by_sender(rpc_results, targets, context)
|
|
179
|
+
|
|
180
|
+
responded = {}
|
|
181
|
+
errors = {}
|
|
182
|
+
rpc_statuscodes = {}
|
|
183
|
+
targets.each do |target|
|
|
184
|
+
rpc_result = by_sender[choria_identity(target)]
|
|
185
|
+
if rpc_result.nil?
|
|
186
|
+
errors[target] = error_output(
|
|
187
|
+
"No response from #{target.safe_name} for #{context}",
|
|
188
|
+
'bolt/choria-no-response'
|
|
189
|
+
)
|
|
190
|
+
elsif rpc_result[:statuscode] > 1
|
|
191
|
+
rpc_statuscodes[target] = rpc_result[:statuscode]
|
|
192
|
+
errors[target] = error_output(
|
|
193
|
+
"#{context} on #{target.safe_name} returned RPC error: " \
|
|
194
|
+
"#{rpc_result[:statusmsg]} (code #{rpc_result[:statuscode]})",
|
|
195
|
+
'bolt/choria-rpc-error'
|
|
196
|
+
)
|
|
197
|
+
else
|
|
198
|
+
rpc_statuscodes[target] = rpc_result[:statuscode]
|
|
199
|
+
if rpc_result[:statuscode] == 1
|
|
200
|
+
logger.warn { "#{context} on #{target.safe_name} had RPC status code #{rpc_result[:statuscode]}: #{rpc_result[:statusmsg]}" }
|
|
201
|
+
end
|
|
202
|
+
responded[target] = rpc_result[:data]
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
{ responded: responded, errors: errors, rpc_failed: false, rpc_statuscodes: rpc_statuscodes }
|
|
206
|
+
rescue StandardError => e
|
|
207
|
+
raise if e.is_a?(Bolt::Error)
|
|
208
|
+
|
|
209
|
+
logger.warn { "#{context} RPC call failed: #{e.class}: #{e.message}" }
|
|
210
|
+
errors = targets.each_with_object({}) do |target, errs|
|
|
211
|
+
errs[target] = error_output("#{context} failed on #{target.safe_name}: #{e.message}",
|
|
212
|
+
'bolt/choria-rpc-failed')
|
|
213
|
+
end
|
|
214
|
+
{ responded: {}, errors: errors, rpc_failed: true, rpc_statuscodes: {} }
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Configure the client, discover agents, partition targets by agent
|
|
218
|
+
# availability, and emit errors for incapable targets.
|
|
219
|
+
#
|
|
220
|
+
# @param targets [Array<Bolt::Target>] Targets to prepare
|
|
221
|
+
# @param agent_name [String] Required agent name (e.g. 'shell', 'bolt_tasks')
|
|
222
|
+
# @param result_opts [Hash] Options passed through to emit_results (:action, :name, :position)
|
|
223
|
+
# @param callback [Proc] Called with :node_start and :node_result events
|
|
224
|
+
# @return [Array] Two-element array:
|
|
225
|
+
# - [Array<Bolt::Target>] Targets that have the required agent
|
|
226
|
+
# - [Array<Bolt::Result>] Error results for targets that lack the agent
|
|
227
|
+
def prepare_targets(targets, agent_name, result_opts, &callback)
|
|
228
|
+
configure_client(targets.first)
|
|
229
|
+
discover_agents(targets)
|
|
230
|
+
|
|
231
|
+
capable, incapable = targets.partition { |target| has_agent?(target, agent_name) }
|
|
232
|
+
|
|
233
|
+
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')
|
|
240
|
+
end
|
|
241
|
+
incapable_results = emit_results(agent_errors, fire_node_start: true, **result_opts, &callback)
|
|
242
|
+
|
|
243
|
+
[capable, incapable_results]
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Index RPC results by sender, keeping only the first response per
|
|
247
|
+
# sender and only from the set of expected identities. Logs and discards
|
|
248
|
+
# responses from unexpected senders and duplicates.
|
|
249
|
+
#
|
|
250
|
+
# @param results [Array<Hash>] Raw MCollective RPC result hashes with :sender keys
|
|
251
|
+
# @param targets [Array<Bolt::Target>] Expected targets (used to build the allowed sender set)
|
|
252
|
+
# @param context [String] Human-readable label for log messages
|
|
253
|
+
# @return [Hash{String => Hash}] Sender identity to first valid RPC result hash
|
|
254
|
+
def index_results_by_sender(results, targets, context)
|
|
255
|
+
expected = targets.to_set { |target| choria_identity(target) }
|
|
256
|
+
by_sender = {}
|
|
257
|
+
results.each do |result|
|
|
258
|
+
sender = result[:sender]
|
|
259
|
+
unless sender
|
|
260
|
+
logger.warn { "Discarding #{context} response with nil sender" }
|
|
261
|
+
next
|
|
262
|
+
end
|
|
263
|
+
unless expected.include?(sender)
|
|
264
|
+
logger.warn { "Discarding #{context} response from unexpected sender '#{sender}'" }
|
|
265
|
+
next
|
|
266
|
+
end
|
|
267
|
+
if by_sender.key?(sender)
|
|
268
|
+
if result[:data] == by_sender[sender][:data]
|
|
269
|
+
logger.debug { "Ignoring duplicate #{context} response from #{sender}" }
|
|
270
|
+
else
|
|
271
|
+
logger.warn { "Ignoring duplicate #{context} response from #{sender} with different data" }
|
|
272
|
+
end
|
|
273
|
+
next
|
|
274
|
+
end
|
|
275
|
+
by_sender[sender] = result
|
|
276
|
+
end
|
|
277
|
+
by_sender
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
end
|