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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/Puppetfile +5 -5
  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/wait.rb +2 -2
  14. data/bolt-modules/file/lib/puppet/functions/file/delete.rb +1 -1
  15. data/bolt-modules/file/lib/puppet/functions/file/exists.rb +1 -1
  16. data/bolt-modules/file/lib/puppet/functions/file/read.rb +1 -1
  17. data/bolt-modules/file/lib/puppet/functions/file/readable.rb +1 -1
  18. data/bolt-modules/file/lib/puppet/functions/file/write.rb +1 -1
  19. data/bolt-modules/log/lib/puppet/functions/log/debug.rb +1 -1
  20. data/bolt-modules/log/lib/puppet/functions/log/error.rb +1 -1
  21. data/bolt-modules/log/lib/puppet/functions/log/fatal.rb +1 -1
  22. data/bolt-modules/log/lib/puppet/functions/log/info.rb +1 -1
  23. data/bolt-modules/log/lib/puppet/functions/log/trace.rb +1 -1
  24. data/bolt-modules/log/lib/puppet/functions/log/warn.rb +1 -1
  25. data/guides/targets.yaml +2 -0
  26. data/lib/bolt/bolt_option_parser.rb +53 -53
  27. data/lib/bolt/cli.rb +21 -2
  28. data/lib/bolt/config/options.rb +18 -18
  29. data/lib/bolt/config/transport/choria.rb +3 -0
  30. data/lib/bolt/config/transport/options.rb +34 -11
  31. data/lib/bolt/executor.rb +4 -0
  32. data/lib/bolt/fiber_executor.rb +46 -2
  33. data/lib/bolt/inventory/options.rb +1 -1
  34. data/lib/bolt/plan_future.rb +5 -1
  35. data/lib/bolt/shell/bash.rb +5 -3
  36. data/lib/bolt/shell/powershell/snippets.rb +20 -1
  37. data/lib/bolt/shell/powershell.rb +2 -2
  38. data/lib/bolt/transport/base.rb +9 -4
  39. data/lib/bolt/transport/choria/agent_discovery.rb +32 -14
  40. data/lib/bolt/transport/choria/bolt_tasks.rb +64 -40
  41. data/lib/bolt/transport/choria/client.rb +66 -49
  42. data/lib/bolt/transport/choria/command_builders.rb +144 -24
  43. data/lib/bolt/transport/choria/file_transfer.rb +134 -0
  44. data/lib/bolt/transport/choria/helpers.rb +25 -20
  45. data/lib/bolt/transport/choria/shell.rb +255 -148
  46. data/lib/bolt/transport/choria.rb +96 -27
  47. data/lib/bolt/util.rb +13 -0
  48. data/lib/bolt/version.rb +1 -1
  49. data/lib/bolt_spec/plans/mock_executor.rb +4 -0
  50. data/libexec/apply_catalog.rb +3 -0
  51. metadata +5 -4
@@ -5,6 +5,11 @@ require_relative '../bolt/plan_future'
5
5
 
6
6
  module Bolt
7
7
  class FiberExecutor
8
+ # Limits how many plans one future can nest, so a plan that runs itself
9
+ # with no condition that stops it fails instead of starting Fibers until
10
+ # memory runs out.
11
+ MAX_NESTED_PLANS = 1000
12
+
8
13
  attr_reader :active_futures, :finished_futures
9
14
 
10
15
  def initialize
@@ -99,6 +104,7 @@ module Bolt
99
104
  # `wait` call, Bolt should pause for long enough that something can
100
105
  # execute before checking again. This mitigates CPU
101
106
  # thrashing.
107
+ return if active_futures.empty?
102
108
  return unless active_futures.all? { |f| %i[returned_immediately unfinished].include?(f.value) }
103
109
 
104
110
  @logger.trace("Nothing can be resumed. Rechecking in 0.5 seconds.")
@@ -121,7 +127,45 @@ module Bolt
121
127
  # Get the PlanFuture object that is currently executing
122
128
  #
123
129
  def get_current_future(fiber:)
124
- all_futures.select { |f| f.fiber == fiber }.first
130
+ all_futures.find { |f| f.fiber == fiber || f.nested_fibers.include?(fiber) }
131
+ end
132
+
133
+ # Run the block on a new Fiber, so it starts with an empty stack instead
134
+ # of adding to the current one. Every plan a plan runs adds dozens of
135
+ # frames, and a Fiber's stack is small and fixed. Values the block yields
136
+ # while it waits are yielded on to the scheduler, and an error raised into
137
+ # the current Fiber, like a wait() timeout, is raised inside the block.
138
+ #
139
+ def run_in_new_fiber(&block)
140
+ future = get_current_future(fiber: Fiber.current)
141
+ return yield unless future
142
+
143
+ if future.nested_fibers.length >= MAX_NESTED_PLANS
144
+ msg = "Plans cannot be nested more than #{MAX_NESTED_PLANS} levels deep. Check for a " \
145
+ "plan that runs itself without a condition that stops it."
146
+ # A plain error, not a Bolt::Error, so plans can't catch it with
147
+ # _catch_errors or catch_errors() and keep a runaway plan going.
148
+ raise msg
149
+ end
150
+
151
+ fiber = Fiber.new(&block)
152
+ future.nested_fibers.push(fiber)
153
+ @logger.trace("Running a nested plan on a new Fiber in future '#{future.name}'")
154
+ begin
155
+ value = fiber.resume
156
+ while fiber.alive?
157
+ begin
158
+ resumed_with = Fiber.yield(value)
159
+ rescue StandardError => e
160
+ value = fiber.raise(e)
161
+ next
162
+ end
163
+ value = fiber.resume(resumed_with)
164
+ end
165
+ value
166
+ ensure
167
+ future.nested_fibers.delete(fiber)
168
+ end
125
169
  end
126
170
 
127
171
  # Get the plan invocation ID for the PlanFuture that is currently executing
@@ -146,7 +190,7 @@ module Bolt
146
190
  # finished. This ensures that we include futures created inside of
147
191
  # futures being waited on.
148
192
  until (futures = get_futures_for_plan(plan_id: plan_id)).map(&:alive?).none?
149
- if futures.map(&:fiber).include?(Fiber.current)
193
+ if futures.include?(get_current_future(fiber: Fiber.current))
150
194
  msg = "The wait() function cannot be called with no arguments inside a " \
151
195
  "background block in the same plan."
152
196
  raise Bolt::Error.new(msg, 'bolt/infinite-wait')
@@ -84,7 +84,7 @@ module Bolt
84
84
  "plugin_hooks" => {
85
85
  description: "Configuration for the Puppet library plugin used to install the " \
86
86
  "Puppet agent on the target. For more information, see " \
87
- "https://docs.openvoxproject.org/openbolt/latest/writing_plugins.html",
87
+ "[plugin hooks](writing_plugins.md#plugin-hooks).",
88
88
  type: Hash,
89
89
  properties: {
90
90
  "puppet_library" => {
@@ -4,7 +4,7 @@ require 'fiber'
4
4
 
5
5
  module Bolt
6
6
  class PlanFuture
7
- attr_reader :fiber, :id, :scope
7
+ attr_reader :fiber, :id, :scope, :nested_fibers
8
8
  attr_accessor :value, :plan_stack
9
9
 
10
10
  def initialize(fiber, id, plan_id:, name: nil, scope: nil)
@@ -24,6 +24,10 @@ module Bolt
24
24
  # since now we're in the calling plan. These IDs are unique to each plan
25
25
  # invocation, not just plan names.
26
26
  @plan_stack = [plan_id]
27
+
28
+ # Fibers this Future started with FiberExecutor#run_in_new_fiber, which
29
+ # still belong to this Future
30
+ @nested_fibers = []
27
31
  end
28
32
 
29
33
  def original_plan
@@ -230,15 +230,17 @@ module Bolt
230
230
  ''
231
231
  end
232
232
 
233
+ # GNU sudo and sudo-rs word these differently
233
234
  def handle_sudo_errors(err)
234
235
  case err
235
- when /^#{conn.user} is not in the sudoers file\./
236
+ when /^#{conn.user} is not in the sudoers file\./,
237
+ /Sorry, user #{conn.user} (may not run|is not allowed to execute)/
236
238
  @logger.trace { err }
237
239
  raise Bolt::Node::EscalateError.new(
238
240
  "User #{conn.user} does not have sudo permission on #{target}",
239
241
  'SUDO_DENIED'
240
242
  )
241
- when /^Sorry, try again\./
243
+ when /^Sorry, try again\./, /Authentication failed, try again/
242
244
  @logger.trace { err }
243
245
  raise Bolt::Node::EscalateError.new(
244
246
  "Sudo password for user #{conn.user} not recognized on #{target}",
@@ -299,7 +301,7 @@ module Bolt
299
301
 
300
302
  result = execute(command)
301
303
  if result.exit_code != 0
302
- raise Bolt::Node::FileError.new("Could not make tmpdir: #{result.stderr.string}", 'TMPDIR_ERROR')
304
+ raise Bolt::Node::FileError.new("Could not make tmpdir #{tmppath}: #{result.stderr.string}", 'TMPDIR_ERROR')
303
305
  end
304
306
 
305
307
  path = tmppath || result.stdout.string.chomp
@@ -1,10 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'base64'
4
+
3
5
  module Bolt
4
6
  class Shell
5
7
  class Powershell < Shell
6
8
  module Snippets
7
9
  class << self
10
+ # An expression that evaluates to the value as a string. The value
11
+ # is sent as base64, so no character in it can end a quoted string
12
+ # or here-string, whatever encoding PowerShell reads the script in.
13
+ # A binary string, such as a command-line argument under the C
14
+ # locale, goes as its bytes, which PowerShell reads as UTF-8.
15
+ def base64_string(value)
16
+ value = value.to_s
17
+ value = value.encode('UTF-8') unless value.encoding == Encoding::BINARY
18
+ "[System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('#{Base64.strict_encode64(value)}'))"
19
+ end
20
+
8
21
  def execute_process(command)
9
22
  <<~PS
10
23
  if ([Console]::InputEncoding -eq [System.Text.Encoding]::UTF8) {
@@ -46,7 +59,7 @@ module Bolt
46
59
 
47
60
  def run_script(arguments, script_path)
48
61
  build_arg_list = arguments.map do |a|
49
- "$invokeArgs.ArgumentList += @'\n#{a}\n'@"
62
+ "$invokeArgs.ArgumentList += #{base64_string(a)}"
50
63
  end.join("\n")
51
64
  <<~PS
52
65
  $invokeArgs = @{
@@ -138,6 +151,12 @@ module Bolt
138
151
  "${boltBaseDir}\\hiera\\lib;" +
139
152
  $ENV:RUBYLIB
140
153
 
154
+ #{json_functions.chomp}
155
+ PS
156
+ end
157
+
158
+ def json_functions
159
+ <<~PS
141
160
  function ConvertFrom-PSCustomObject
142
161
  {
143
162
  PARAM([Parameter(ValueFromPipeline = $true)] $InputObject)
@@ -88,12 +88,12 @@ module Bolt
88
88
 
89
89
  def env_declarations(env_vars)
90
90
  env_vars.map do |var, val|
91
- "[Environment]::SetEnvironmentVariable('#{var}', @'\n#{val}\n'@)"
91
+ "[Environment]::SetEnvironmentVariable(#{quote_string(var.to_s)}, #{Snippets.base64_string(val)})"
92
92
  end
93
93
  end
94
94
 
95
95
  def quote_string(string)
96
- "'" + string.gsub("'", "''") + "'"
96
+ "'" + Bolt::Util.escape_powershell_single_quotes(string) + "'"
97
97
  end
98
98
 
99
99
  def write_executable(dir, file, filename = nil)
@@ -165,18 +165,23 @@ module Bolt
165
165
  #
166
166
  # Transports may override this method to implement their own batch processing.
167
167
  def batch_download(targets, source, destination, options = {}, position = [], &callback)
168
- require 'erb'
169
-
170
168
  assert_batch_size_one("batch_download()", targets)
171
169
  target = targets.first
172
170
  with_events(target, callback, 'download', position) do
173
- escaped_name = ERB::Util.url_encode(target.safe_name)
174
- target_destination = File.expand_path(escaped_name, destination)
171
+ target_destination = download_dir(destination, target)
175
172
  @logger.debug { "Downloading: '#{source}' on #{target.safe_name} to #{target_destination}" }
176
173
  download(target, source, target_destination, options)
177
174
  end
178
175
  end
179
176
 
177
+ # The directory under the destination that receives one target's
178
+ # download, named after the target so that a batch keeps them apart.
179
+ def download_dir(destination, target)
180
+ require 'erb'
181
+
182
+ File.expand_path(ERB::Util.url_encode(target.safe_name), destination)
183
+ end
184
+
180
185
  def batch_connected?(targets)
181
186
  assert_batch_size_one("connected?()", targets)
182
187
  connected?(targets.first)
@@ -3,10 +3,9 @@
3
3
  module Bolt
4
4
  module Transport
5
5
  class Choria
6
- SHELL_MIN_VERSION = '1.2.1'
7
-
8
6
  AGENT_MIN_VERSIONS = {
9
- 'shell' => SHELL_MIN_VERSION
7
+ 'shell' => '1.2.1',
8
+ 'file_transfer' => '1.0.0'
10
9
  }.freeze
11
10
 
12
11
  # Discover agents and detect OS on targets via two batched RPC calls
@@ -14,18 +13,20 @@ module Bolt
14
13
  # Populates @agent_cache with { agents: [...], os: 'redhat' | 'windows' | ... }.
15
14
  #
16
15
  # @param targets [Array<Bolt::Target>] Targets to discover agents on
16
+ # @return [Hash{Bolt::Target => Hash}] What discover_agent_list returns for the uncached
17
+ # targets, or an empty hash when every target was already discovered.
17
18
  def discover_agents(targets)
18
- uncached = targets.reject { |target| @agent_cache.key?(choria_identity(target)) }
19
- return if uncached.empty?
19
+ uncached = targets.reject { |target| discovered?(target) }
20
+ return {} if uncached.empty?
20
21
 
21
22
  logger.debug { "Discovering agents on #{target_count(uncached)}" }
22
- discover_agent_list(uncached)
23
+ failures = discover_agent_list(uncached)
23
24
  discover_os_family(uncached)
25
+ failures
26
+ end
24
27
 
25
- uncached.each do |target|
26
- identity = choria_identity(target)
27
- logger.warn { "No response from #{identity} during agent discovery" } unless @agent_cache.key?(identity)
28
- end
28
+ def discovered?(target)
29
+ @agent_cache.key?(choria_identity(target))
29
30
  end
30
31
 
31
32
  def has_agent?(target, agent_name)
@@ -40,26 +41,37 @@ module Bolt
40
41
  # and populate @agent_cache with agent lists.
41
42
  #
42
43
  # @param targets [Array<Bolt::Target>] Targets to query for agent inventory
44
+ # @return [Hash{Bolt::Target => Hash}] The error output of each target that did not answer,
45
+ # answered with an RPC error or answered without an agent list, or of every target when
46
+ # the call failed as a whole, such as when the client could not connect. An empty hash
47
+ # when discovery itself raises, which leaves those targets undiscovered.
43
48
  def discover_agent_list(targets)
44
49
  response = rpc_request('rpcutil', targets, 'rpcutil.agent_inventory') do |client|
45
50
  client.agent_inventory
46
51
  end
47
- response[:errors].each { |target, output| logger.debug { "agent_inventory failed for #{target.safe_name}: #{output[:error]}" } }
52
+ response[:errors].each { |target, output| logger.warn { "agent_inventory failed for #{target.safe_name}: #{output[:error]}" } }
48
53
 
54
+ malformed = {}
49
55
  response[:responded].each do |target, data|
50
56
  sender = choria_identity(target)
51
- agents = filter_agents(sender, data[:agents])
57
+ # A node can send data that isn't a hash, which would raise and stop
58
+ # discovery for every target after it
59
+ agents = filter_agents(sender, data[:agents]) if data.is_a?(Hash)
52
60
  unless agents
53
- logger.warn { "Unexpected agent_inventory response from #{sender}. This target will be treated as unreachable." }
61
+ logger.warn { "Unexpected agent_inventory response from #{sender}" }
62
+ malformed[target] = error_output("rpcutil.agent_inventory on #{target.safe_name} returned no agent list",
63
+ 'bolt/choria-missing-data')
54
64
  next
55
65
  end
56
66
  @agent_cache[sender] = { agents: agents }
57
67
  logger.debug { "Discovered agents on #{sender}: #{agents.join(', ')}" }
58
68
  end
69
+ response[:errors].merge(malformed)
59
70
  rescue StandardError => e
60
71
  raise if e.is_a?(Bolt::Error)
61
72
 
62
73
  logger.warn { "Agent discovery failed: #{e.class}: #{e.message}" }
74
+ {}
63
75
  end
64
76
 
65
77
  # Detect the OS family on targets via rpcutil.get_fact and update
@@ -68,7 +80,7 @@ module Bolt
68
80
  # @param targets [Array<Bolt::Target>] Targets to detect OS on
69
81
  def discover_os_family(targets)
70
82
  # Only fetch OS for targets that responded to agent_inventory
71
- responded = targets.select { |target| @agent_cache.key?(choria_identity(target)) }
83
+ responded = targets.select { |target| discovered?(target) }
72
84
  return if responded.empty?
73
85
 
74
86
  response = rpc_request('rpcutil', responded, 'rpcutil.get_fact') do |client|
@@ -82,6 +94,10 @@ module Bolt
82
94
 
83
95
  response[:responded].each do |target, data|
84
96
  sender = choria_identity(target)
97
+ unless data.is_a?(Hash)
98
+ logger.warn { "Unexpected get_fact response from #{sender}. Defaulting to POSIX command syntax." }
99
+ next
100
+ end
85
101
  os_family = data[:value].to_s.downcase
86
102
  if os_family.empty?
87
103
  logger.warn { "os.family fact is empty on #{sender}. Defaulting to POSIX command syntax." }
@@ -107,6 +123,8 @@ module Bolt
107
123
  return nil unless agent_list.is_a?(Array)
108
124
 
109
125
  agent_list.filter_map do |entry|
126
+ next unless entry.is_a?(Hash)
127
+
110
128
  name = entry['agent']
111
129
  next unless name
112
130
 
@@ -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