openbolt 5.4.0 → 5.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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