openbolt 9.0.0.pre.rc1 → 9.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/Puppetfile +5 -5
- data/bolt-modules/boltlib/lib/puppet/datatypes/result.rb +1 -1
- data/bolt-modules/boltlib/lib/puppet/datatypes/resultset.rb +2 -2
- data/bolt-modules/boltlib/lib/puppet/datatypes/target.rb +2 -2
- data/bolt-modules/boltlib/lib/puppet/functions/apply_prep.rb +2 -2
- data/bolt-modules/boltlib/lib/puppet/functions/background.rb +1 -1
- data/bolt-modules/boltlib/lib/puppet/functions/fail_plan.rb +1 -1
- data/bolt-modules/boltlib/lib/puppet/functions/get_resources.rb +1 -1
- data/bolt-modules/boltlib/lib/puppet/functions/puppetdb_command.rb +1 -1
- data/bolt-modules/boltlib/lib/puppet/functions/puppetdb_query.rb +1 -1
- data/bolt-modules/boltlib/lib/puppet/functions/run_plan.rb +27 -4
- data/bolt-modules/boltlib/lib/puppet/functions/wait.rb +2 -2
- data/bolt-modules/file/lib/puppet/functions/file/delete.rb +1 -1
- data/bolt-modules/file/lib/puppet/functions/file/exists.rb +1 -1
- data/bolt-modules/file/lib/puppet/functions/file/read.rb +1 -1
- data/bolt-modules/file/lib/puppet/functions/file/readable.rb +1 -1
- data/bolt-modules/file/lib/puppet/functions/file/write.rb +1 -1
- data/bolt-modules/log/lib/puppet/functions/log/debug.rb +1 -1
- data/bolt-modules/log/lib/puppet/functions/log/error.rb +1 -1
- data/bolt-modules/log/lib/puppet/functions/log/fatal.rb +1 -1
- data/bolt-modules/log/lib/puppet/functions/log/info.rb +1 -1
- data/bolt-modules/log/lib/puppet/functions/log/trace.rb +1 -1
- data/bolt-modules/log/lib/puppet/functions/log/warn.rb +1 -1
- data/guides/targets.yaml +2 -0
- data/lib/bolt/bolt_option_parser.rb +53 -53
- data/lib/bolt/cli.rb +21 -2
- data/lib/bolt/config/options.rb +18 -18
- data/lib/bolt/config/transport/choria.rb +3 -0
- data/lib/bolt/config/transport/options.rb +34 -11
- data/lib/bolt/executor.rb +4 -0
- data/lib/bolt/fiber_executor.rb +46 -2
- data/lib/bolt/inventory/options.rb +1 -1
- data/lib/bolt/plan_future.rb +5 -1
- data/lib/bolt/shell/bash.rb +5 -3
- data/lib/bolt/shell/powershell/snippets.rb +20 -1
- data/lib/bolt/shell/powershell.rb +2 -2
- data/lib/bolt/transport/base.rb +9 -4
- data/lib/bolt/transport/choria/agent_discovery.rb +32 -14
- data/lib/bolt/transport/choria/bolt_tasks.rb +64 -40
- data/lib/bolt/transport/choria/client.rb +66 -49
- data/lib/bolt/transport/choria/command_builders.rb +144 -24
- data/lib/bolt/transport/choria/file_transfer.rb +134 -0
- data/lib/bolt/transport/choria/helpers.rb +25 -20
- data/lib/bolt/transport/choria/shell.rb +255 -148
- data/lib/bolt/transport/choria.rb +96 -27
- data/lib/bolt/util.rb +13 -0
- data/lib/bolt/version.rb +1 -1
- data/lib/bolt_spec/plans/mock_executor.rb +4 -0
- data/libexec/apply_catalog.rb +3 -0
- metadata +5 -4
data/lib/bolt/fiber_executor.rb
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
-
"
|
|
87
|
+
"[plugin hooks](writing_plugins.md#plugin-hooks).",
|
|
88
88
|
type: Hash,
|
|
89
89
|
properties: {
|
|
90
90
|
"puppet_library" => {
|
data/lib/bolt/plan_future.rb
CHANGED
|
@@ -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
|
data/lib/bolt/shell/bash.rb
CHANGED
|
@@ -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 +=
|
|
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(
|
|
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
|
-
"'" +
|
|
96
|
+
"'" + Bolt::Util.escape_powershell_single_quotes(string) + "'"
|
|
97
97
|
end
|
|
98
98
|
|
|
99
99
|
def write_executable(dir, file, filename = nil)
|
data/lib/bolt/transport/base.rb
CHANGED
|
@@ -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
|
-
|
|
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' =>
|
|
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|
|
|
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
|
-
|
|
26
|
-
|
|
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.
|
|
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
|
-
|
|
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}
|
|
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|
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
#
|
|
73
|
-
#
|
|
74
|
-
#
|
|
75
|
-
#
|
|
76
|
-
#
|
|
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[:
|
|
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
|
-
|
|
98
|
-
|
|
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 =
|
|
113
|
+
task_id = started.values.first&.dig(:task_id)
|
|
103
114
|
unless task_id
|
|
104
|
-
no_id_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(
|
|
115
|
-
{ failed_results: failed_results, targets:
|
|
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
|
-
|
|
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
|
|
144
|
+
next { rpc_failed: true } if response[:rpc_failed]
|
|
132
145
|
|
|
133
|
-
done = response[:errors]
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
|
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)
|
|
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
|