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
|
@@ -79,7 +79,7 @@ module Bolt
|
|
|
79
79
|
logger.debug { "Choria brokers overridden: #{brokers.join(', ')}" }
|
|
80
80
|
end
|
|
81
81
|
|
|
82
|
-
if opts['ssl-ca']
|
|
82
|
+
if opts['ssl-ca']
|
|
83
83
|
unreadable = %w[ssl-ca ssl-cert ssl-key].find { |key| !File.readable?(opts[key]) }
|
|
84
84
|
if unreadable
|
|
85
85
|
raise Bolt::Error.new(
|
|
@@ -101,9 +101,9 @@ module Bolt
|
|
|
101
101
|
end
|
|
102
102
|
|
|
103
103
|
# Create an MCollective::RPC::Client for one or more targets.
|
|
104
|
-
#
|
|
105
|
-
#
|
|
106
|
-
#
|
|
104
|
+
# Uses MCollective's direct addressing mode (client.discover(nodes:))
|
|
105
|
+
# to skip broadcast discovery and send requests directly to the
|
|
106
|
+
# specified nodes.
|
|
107
107
|
#
|
|
108
108
|
# Note that when the client is created, if the shell agent isn't already
|
|
109
109
|
# installed on the OpenBolt controller node, then the shell DDL that we
|
|
@@ -113,24 +113,16 @@ module Bolt
|
|
|
113
113
|
# DDL is already included in the choria-mcorpc-support gem.
|
|
114
114
|
#
|
|
115
115
|
# @param agent_name [String] MCollective agent name (e.g. 'shell', 'bolt_tasks')
|
|
116
|
-
# @param targets [
|
|
116
|
+
# @param targets [Array<Bolt::Target>] Targets to address
|
|
117
117
|
# @param timeout [Numeric] RPC call timeout in seconds
|
|
118
118
|
# @return [MCollective::RPC::Client] Configured client with direct addressing enabled
|
|
119
119
|
def create_rpc_client(agent_name, targets, timeout)
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
options[:connection_timeout] = targets.first.options['broker-timeout']
|
|
120
|
+
client = MCollective::RPC::Client.new(agent_name, options: client_options(targets))
|
|
121
|
+
# Set after the build, which replaces a timeout of exactly 5, the
|
|
122
|
+
# gem's default, with the DDL timeout plus the discovery timeout.
|
|
123
|
+
client.timeout = timeout
|
|
125
124
|
|
|
126
|
-
|
|
127
|
-
options[:collective] = collective if collective
|
|
128
|
-
|
|
129
|
-
client = MCollective::RPC::Client.new(agent_name, options: options)
|
|
130
|
-
client.progress = false
|
|
131
|
-
|
|
132
|
-
identities = targets.map { |target| choria_identity(target) }.uniq
|
|
133
|
-
client.discover(nodes: identities)
|
|
125
|
+
client.discover(nodes: identities_of(targets))
|
|
134
126
|
|
|
135
127
|
client
|
|
136
128
|
end
|
|
@@ -140,13 +132,14 @@ module Bolt
|
|
|
140
132
|
#
|
|
141
133
|
# Results are split based on MCollective RPC statuscodes:
|
|
142
134
|
# - statuscode 0: action completed successfully (:responded)
|
|
143
|
-
# - statuscode 1 (RPCAborted): action
|
|
144
|
-
# problem (:
|
|
145
|
-
#
|
|
146
|
-
#
|
|
147
|
-
#
|
|
148
|
-
#
|
|
149
|
-
#
|
|
135
|
+
# - statuscode 1 (RPCAborted): the action could not run or reported a
|
|
136
|
+
# problem. An error (:errors) by default, since the shell and rpcutil
|
|
137
|
+
# agents never answer 1 with data the transport can use, and a denied
|
|
138
|
+
# action policy answers 1 as well. With accept_aborted set the reply
|
|
139
|
+
# stays under :responded with its data and its status message goes
|
|
140
|
+
# under :aborted, which bolt_tasks needs, since it answers 1 for a
|
|
141
|
+
# task that ran and failed with the data still valid. Such a caller
|
|
142
|
+
# must not assume :responded means success.
|
|
150
143
|
# - statuscode 2-5: RPC infrastructure error (:errors)
|
|
151
144
|
# - no response: target didn't reply (:errors)
|
|
152
145
|
# - exception: total RPC failure (rpc_failed: true)
|
|
@@ -159,27 +152,27 @@ module Bolt
|
|
|
159
152
|
# @param agent [String] MCollective agent name (e.g. 'shell', 'bolt_tasks', 'rpcutil')
|
|
160
153
|
# @param targets [Bolt::Target, Array<Bolt::Target>] One or more targets to address
|
|
161
154
|
# @param context [String] Human-readable label for logging (e.g. 'shell.start')
|
|
155
|
+
# @param accept_aborted [Boolean] Whether a statuscode 1 reply counts as a response, default is false
|
|
162
156
|
# @yield [MCollective::RPC::Client] The configured RPC client to invoke an action on
|
|
163
157
|
# @return [Hash] with keys:
|
|
164
|
-
# - :responded [Hash] Targets where the action completed (statuscode 0
|
|
165
|
-
# mapped to their response data
|
|
158
|
+
# - :responded [Hash] Targets where the action completed (statuscode 0, or 1 with
|
|
159
|
+
# accept_aborted), mapped to their response data
|
|
166
160
|
# - :errors [Hash] Targets with RPC errors or no response, mapped to error output hashes
|
|
167
161
|
# - :rpc_failed [Boolean] True when the entire RPC call failed
|
|
168
|
-
# - :
|
|
169
|
-
#
|
|
170
|
-
#
|
|
171
|
-
def rpc_request(agent, targets, context)
|
|
162
|
+
# - :aborted [Hash] Targets in :responded whose reply had statuscode 1,
|
|
163
|
+
# mapped to the agent's status message, so a caller can report the
|
|
164
|
+
# agent's reason
|
|
165
|
+
def rpc_request(agent, targets, context, accept_aborted: false)
|
|
172
166
|
targets = Array(targets)
|
|
173
167
|
rpc_results = @rpc_mutex.synchronize do
|
|
174
|
-
|
|
175
|
-
client = create_rpc_client(agent, targets, rpc_timeout)
|
|
168
|
+
client = create_rpc_client(agent, targets, targets.first.options['rpc-timeout'])
|
|
176
169
|
yield(client)
|
|
177
170
|
end
|
|
178
171
|
by_sender = index_results_by_sender(rpc_results, targets, context)
|
|
179
172
|
|
|
180
173
|
responded = {}
|
|
181
174
|
errors = {}
|
|
182
|
-
|
|
175
|
+
aborted = {}
|
|
183
176
|
targets.each do |target|
|
|
184
177
|
rpc_result = by_sender[choria_identity(target)]
|
|
185
178
|
if rpc_result.nil?
|
|
@@ -187,22 +180,21 @@ module Bolt
|
|
|
187
180
|
"No response from #{target.safe_name} for #{context}",
|
|
188
181
|
'bolt/choria-no-response'
|
|
189
182
|
)
|
|
190
|
-
elsif rpc_result[:statuscode] > 1
|
|
191
|
-
rpc_statuscodes[target] = rpc_result[:statuscode]
|
|
183
|
+
elsif rpc_result[:statuscode] > 1 || (rpc_result[:statuscode] == 1 && !accept_aborted)
|
|
192
184
|
errors[target] = error_output(
|
|
193
185
|
"#{context} on #{target.safe_name} returned RPC error: " \
|
|
194
186
|
"#{rpc_result[:statusmsg]} (code #{rpc_result[:statuscode]})",
|
|
195
187
|
'bolt/choria-rpc-error'
|
|
196
188
|
)
|
|
197
189
|
else
|
|
198
|
-
rpc_statuscodes[target] = rpc_result[:statuscode]
|
|
199
190
|
if rpc_result[:statuscode] == 1
|
|
200
191
|
logger.warn { "#{context} on #{target.safe_name} had RPC status code #{rpc_result[:statuscode]}: #{rpc_result[:statusmsg]}" }
|
|
192
|
+
aborted[target] = rpc_result[:statusmsg]
|
|
201
193
|
end
|
|
202
194
|
responded[target] = rpc_result[:data]
|
|
203
195
|
end
|
|
204
196
|
end
|
|
205
|
-
{ responded: responded, errors: errors, rpc_failed: false,
|
|
197
|
+
{ responded: responded, errors: errors, rpc_failed: false, aborted: aborted }
|
|
206
198
|
rescue StandardError => e
|
|
207
199
|
raise if e.is_a?(Bolt::Error)
|
|
208
200
|
|
|
@@ -211,11 +203,26 @@ module Bolt
|
|
|
211
203
|
errs[target] = error_output("#{context} failed on #{target.safe_name}: #{e.message}",
|
|
212
204
|
'bolt/choria-rpc-failed')
|
|
213
205
|
end
|
|
214
|
-
{ responded: {}, errors: errors, rpc_failed: true,
|
|
206
|
+
{ responded: {}, errors: errors, rpc_failed: true, aborted: {} }
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# The client options the targets' configuration gives: the broker
|
|
210
|
+
# timeout and the collective over the gem's defaults. The file
|
|
211
|
+
# transfer library builds its own clients from the same hash, under
|
|
212
|
+
# the same RPC mutex.
|
|
213
|
+
#
|
|
214
|
+
# @param targets [Array<Bolt::Target>] The targets of one batch
|
|
215
|
+
# @return [Hash]
|
|
216
|
+
def client_options(targets)
|
|
217
|
+
options = MCollective::Util.default_options
|
|
218
|
+
options[:connection_timeout] = targets.first.options['broker-timeout']
|
|
219
|
+
options[:collective] = collective_for(targets.first)
|
|
220
|
+
options
|
|
215
221
|
end
|
|
216
222
|
|
|
217
|
-
#
|
|
218
|
-
# availability, and emit errors for
|
|
223
|
+
# Fire node_start for every target, configure the client, discover
|
|
224
|
+
# agents, partition targets by agent availability, and emit errors for
|
|
225
|
+
# incapable targets.
|
|
219
226
|
#
|
|
220
227
|
# @param targets [Array<Bolt::Target>] Targets to prepare
|
|
221
228
|
# @param agent_name [String] Required agent name (e.g. 'shell', 'bolt_tasks')
|
|
@@ -225,24 +232,34 @@ module Bolt
|
|
|
225
232
|
# - [Array<Bolt::Target>] Targets that have the required agent
|
|
226
233
|
# - [Array<Bolt::Result>] Error results for targets that lack the agent
|
|
227
234
|
def prepare_targets(targets, agent_name, result_opts, &callback)
|
|
235
|
+
targets.each { |target| callback&.call(type: :node_start, target: target) }
|
|
228
236
|
configure_client(targets.first)
|
|
229
|
-
discover_agents(targets)
|
|
237
|
+
discovery_failures = discover_agents(targets)
|
|
230
238
|
|
|
231
239
|
capable, incapable = targets.partition { |target| has_agent?(target, agent_name) }
|
|
232
240
|
|
|
233
241
|
agent_errors = incapable.each_with_object({}) do |target, errors|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
errors[target] = error_output(msg, 'bolt/choria-agent-not-available')
|
|
242
|
+
errors[target] = if discovered?(target)
|
|
243
|
+
error_output("The '#{agent_name}' agent is not available on #{target.safe_name}.", 'bolt/choria-agent-not-available')
|
|
244
|
+
else
|
|
245
|
+
discovery_failures[target] || undiscovered_error(target)
|
|
246
|
+
end
|
|
240
247
|
end
|
|
241
|
-
incapable_results = emit_results(agent_errors,
|
|
248
|
+
incapable_results = emit_results(agent_errors, **result_opts, &callback)
|
|
242
249
|
|
|
243
250
|
[capable, incapable_results]
|
|
244
251
|
end
|
|
245
252
|
|
|
253
|
+
# The error output for a target that discovery has no agent list and no
|
|
254
|
+
# failure for, which happens when discovery raised before it finished.
|
|
255
|
+
#
|
|
256
|
+
# @param target [Bolt::Target]
|
|
257
|
+
# @return [Hash] An error output hash
|
|
258
|
+
def undiscovered_error(target)
|
|
259
|
+
error_output("No agent information available for #{target.safe_name} (agent discovery failed)",
|
|
260
|
+
'bolt/choria-agent-not-available')
|
|
261
|
+
end
|
|
262
|
+
|
|
246
263
|
# Index RPC results by sender, keeping only the first response per
|
|
247
264
|
# sender and only from the set of expected identities. Logs and discards
|
|
248
265
|
# responses from unexpected senders and duplicates.
|
|
@@ -23,6 +23,29 @@ module Bolt
|
|
|
23
23
|
end
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
+
# Build the command that creates a tmpdir. On POSIX it is mkdir -m 700
|
|
27
|
+
# without -p, so it fails when the directory the tmpdir goes into
|
|
28
|
+
# doesn't exist, like it does with the SSH transport. The Windows
|
|
29
|
+
# command leaves the tmpdir open only to the account the agent runs as
|
|
30
|
+
# and to the Administrators group, which the directories and files in
|
|
31
|
+
# it inherit, and it fails when it cannot set that.
|
|
32
|
+
#
|
|
33
|
+
# @param target [Bolt::Target] Used for platform detection
|
|
34
|
+
# @param tmpdir [String] Absolute path to the tmpdir on the remote node
|
|
35
|
+
# @return [String] Shell command
|
|
36
|
+
def make_tmpdir_command(target, tmpdir)
|
|
37
|
+
return "mkdir -m 700 #{Shellwords.shellescape(tmpdir)}" unless windows_target?(target)
|
|
38
|
+
|
|
39
|
+
"$ErrorActionPreference = 'Stop'; $dir = '#{ps_escape(tmpdir)}'; " \
|
|
40
|
+
'$null = New-Item -ItemType Directory -Force -Path $dir; ' \
|
|
41
|
+
'$acl = Get-Acl -LiteralPath $dir; $acl.SetAccessRuleProtection($true, $false); ' \
|
|
42
|
+
'foreach ($sid in @([Security.Principal.WindowsIdentity]::GetCurrent().User, ' \
|
|
43
|
+
"(New-Object Security.Principal.SecurityIdentifier('S-1-5-32-544')))) { " \
|
|
44
|
+
'$acl.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule($sid, ' \
|
|
45
|
+
"'FullControl', 'ContainerInherit, ObjectInherit', 'None', 'Allow'))) }; " \
|
|
46
|
+
'Set-Acl -LiteralPath $dir -AclObject $acl'
|
|
47
|
+
end
|
|
48
|
+
|
|
26
49
|
# Build a chmod +x command. Returns nil on Windows (not needed).
|
|
27
50
|
#
|
|
28
51
|
# @param target [Bolt::Target] Used for platform detection
|
|
@@ -43,19 +66,88 @@ module Bolt
|
|
|
43
66
|
"rm -rf #{Shellwords.shellescape(path)}"
|
|
44
67
|
end
|
|
45
68
|
|
|
69
|
+
# Build a command that removes the shell agent's job directories for
|
|
70
|
+
# the given handles, under the agent's default state path. A handle
|
|
71
|
+
# without a directory on the target is skipped, so 1 command can
|
|
72
|
+
# contain the handles of every target it goes to. On Windows a failed
|
|
73
|
+
# removal stops the command, so it exits 1 like a failed rm does.
|
|
74
|
+
#
|
|
75
|
+
# @param target [Bolt::Target] Used for platform detection
|
|
76
|
+
# @param handles [Array<String>] Job handles, each checked to be a UUID
|
|
77
|
+
# @return [String] Shell command
|
|
78
|
+
def remove_job_dirs_command(target, handles)
|
|
79
|
+
if windows_target?(target)
|
|
80
|
+
paths = handles.map { |handle| "'C:/ProgramData/mcollective-shell/#{handle}'" }.join(', ')
|
|
81
|
+
"foreach ($path in @(#{paths})) { if (Test-Path -LiteralPath $path) { " \
|
|
82
|
+
'Remove-Item -LiteralPath $path -Recurse -Force -ErrorAction Stop } }'
|
|
83
|
+
else
|
|
84
|
+
"rm -rf #{handles.map { |handle| "/var/run/mcollective-shell/#{handle}" }.join(' ')}"
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
46
88
|
# Build a command that writes base64-encoded content to a file
|
|
47
89
|
# after decoding the content. Requires base64 CLI on POSIX targets.
|
|
48
90
|
#
|
|
49
91
|
# @param target [Bolt::Target] Used for platform detection
|
|
50
92
|
# @param content_b64 [String] Base64-encoded file content
|
|
51
93
|
# @param dest [String] Absolute destination path on the remote node
|
|
94
|
+
# @param append [Boolean] Whether to add the content to the end of the file instead of replacing it
|
|
52
95
|
# @return [String] Shell command
|
|
53
|
-
def upload_file_command(target, content_b64, dest)
|
|
54
|
-
if windows_target?(target)
|
|
96
|
+
def upload_file_command(target, content_b64, dest, append: false)
|
|
97
|
+
if windows_target?(target) && append
|
|
98
|
+
"$bytes = [Convert]::FromBase64String('#{content_b64}'); " \
|
|
99
|
+
"$stream = [IO.File]::Open('#{ps_escape(dest)}', 'Append', 'Write'); " \
|
|
100
|
+
'try { $stream.Write($bytes, 0, $bytes.Length) } finally { $stream.Close() }'
|
|
101
|
+
elsif windows_target?(target)
|
|
55
102
|
"[IO.File]::WriteAllBytes('#{ps_escape(dest)}', " \
|
|
56
103
|
"[Convert]::FromBase64String('#{content_b64}'))"
|
|
57
104
|
else
|
|
58
|
-
"printf '%s' #{Shellwords.shellescape(content_b64)} | base64 -d > #{Shellwords.shellescape(dest)}"
|
|
105
|
+
"printf '%s' #{Shellwords.shellescape(content_b64)} | base64 -d #{append ? '>>' : '>'} #{Shellwords.shellescape(dest)}"
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# The path of the run file in a tmpdir. A script or task command can
|
|
110
|
+
# contain arguments, env var values and task parameters, so it goes to
|
|
111
|
+
# the nodes in this file instead of as the shell agent's start command,
|
|
112
|
+
# which the agent keeps on disk and returns in shell.list replies.
|
|
113
|
+
#
|
|
114
|
+
# @param target [Bolt::Target] Used for platform detection
|
|
115
|
+
# @param tmpdir [String] Absolute path to the tmpdir on the remote node
|
|
116
|
+
# @return [String] Absolute path to the run file
|
|
117
|
+
def run_file_path(target, tmpdir)
|
|
118
|
+
join_path(target, tmpdir, windows_target?(target) ? 'bolt_run.ps1' : 'bolt_run.sh')
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Build the content of a run file. On POSIX it removes itself as soon
|
|
122
|
+
# as sh starts reading it, which keeps reading from the open file. A
|
|
123
|
+
# single command then replaces sh through exec, so shell.kill reaches
|
|
124
|
+
# it. A pipeline doesn't, since exec would run printf as its own
|
|
125
|
+
# process with the task input in its arguments. On Windows the content
|
|
126
|
+
# runs in a script block, which would hide a failing last command from
|
|
127
|
+
# powershell.exe, so it exits 1 then, as the command itself would.
|
|
128
|
+
#
|
|
129
|
+
# @param target [Bolt::Target] Used for platform detection
|
|
130
|
+
# @param command [String] The command the run file contains
|
|
131
|
+
# @param pipeline [Boolean] Whether the command is a pipeline, default is false
|
|
132
|
+
# @return [String] File content
|
|
133
|
+
def run_file_content(target, command, pipeline: false)
|
|
134
|
+
return "#{command}\nif (-not $?) { exit 1 }" if windows_target?(target)
|
|
135
|
+
|
|
136
|
+
"rm -f -- \"$0\"\n#{'exec ' unless pipeline}#{command}\n"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Build the start command that runs a run file. On Windows it reads the
|
|
140
|
+
# file and removes it before running what was in it.
|
|
141
|
+
#
|
|
142
|
+
# @param target [Bolt::Target] Used for platform detection
|
|
143
|
+
# @param path [String] Absolute path to the run file
|
|
144
|
+
# @return [String] Shell command
|
|
145
|
+
def run_file_command(target, path)
|
|
146
|
+
if windows_target?(target)
|
|
147
|
+
"$boltRunFile = '#{ps_escape(path)}'; $boltRunScript = [IO.File]::ReadAllText($boltRunFile); " \
|
|
148
|
+
'Remove-Item -LiteralPath $boltRunFile -Force; & ([ScriptBlock]::Create($boltRunScript))'
|
|
149
|
+
else
|
|
150
|
+
"sh #{Shellwords.shellescape(path)}"
|
|
59
151
|
end
|
|
60
152
|
end
|
|
61
153
|
|
|
@@ -64,46 +156,58 @@ module Bolt
|
|
|
64
156
|
#
|
|
65
157
|
# @param target [Bolt::Target] Used for platform detection
|
|
66
158
|
# @param command [String] The command to prepend env vars to
|
|
67
|
-
# @param env_vars [Hash{String =>
|
|
159
|
+
# @param env_vars [Hash{String => Object}, nil] Variable names to values, which become strings
|
|
68
160
|
# @param context [String] Description for error messages (e.g., 'task argument')
|
|
69
161
|
# @return [String] Command with env vars prepended
|
|
70
162
|
def prepend_env_vars(target, command, env_vars, context)
|
|
71
163
|
return command unless env_vars&.any?
|
|
72
164
|
|
|
73
|
-
env_vars.each_key { |key| validate_env_key!(key, context) }
|
|
74
|
-
|
|
75
165
|
if windows_target?(target)
|
|
76
|
-
|
|
77
|
-
"#{set_stmts.join('; ')}; & #{command}"
|
|
166
|
+
"#{powershell_env_statements(env_vars, context)}; #{command}"
|
|
78
167
|
else
|
|
168
|
+
env_vars.each_key { |key| validate_env_key!(key, context) }
|
|
79
169
|
env_str = env_vars.map { |key, val| "#{key}=#{Shellwords.shellescape(val)}" }.join(' ')
|
|
80
170
|
"/usr/bin/env #{env_str} #{command}"
|
|
81
171
|
end
|
|
82
172
|
end
|
|
83
173
|
|
|
174
|
+
# Build the PowerShell statements that set environment variables,
|
|
175
|
+
# after validating each name.
|
|
176
|
+
#
|
|
177
|
+
# @param env_vars [Hash{String => Object}] Variable names to values, which become strings
|
|
178
|
+
# @param context [String] Description for error messages (e.g., 'task argument')
|
|
179
|
+
# @return [String] The statements joined with '; '
|
|
180
|
+
def powershell_env_statements(env_vars, context)
|
|
181
|
+
env_vars.each_key { |key| validate_env_key!(key, context) }
|
|
182
|
+
env_vars.map { |key, val| "$env:#{key} = '#{ps_escape(val.to_s)}'" }.join('; ')
|
|
183
|
+
end
|
|
184
|
+
|
|
84
185
|
# Build a command that pipes data to another command via stdin.
|
|
85
186
|
#
|
|
86
187
|
# @param target [Bolt::Target] Used for platform detection
|
|
87
188
|
# @param data [String] Data to pipe (typically JSON task arguments)
|
|
88
|
-
# @param command [String] The command to receive stdin
|
|
189
|
+
# @param command [String] The command to receive stdin, which on Windows must
|
|
190
|
+
# be a PowerShell command such as one from executable_command
|
|
89
191
|
# @return [String] Shell command with stdin piping
|
|
90
192
|
def stdin_pipe_command(target, data, command)
|
|
91
193
|
if windows_target?(target)
|
|
92
194
|
# Use a here-string (@'...'@) to avoid escaping issues with
|
|
93
|
-
# large JSON payloads.
|
|
94
|
-
|
|
195
|
+
# large JSON payloads. Its content is literal as long as no line
|
|
196
|
+
# starts with a single quote character and @, which single-line
|
|
197
|
+
# JSON never does.
|
|
198
|
+
"@'\n#{data}\n'@ | #{command}"
|
|
95
199
|
else
|
|
96
200
|
"printf '%s' #{Shellwords.shellescape(data)} | #{command}"
|
|
97
201
|
end
|
|
98
202
|
end
|
|
99
203
|
|
|
100
|
-
# Escape a
|
|
204
|
+
# Escape a value for use as a shell argument on the target platform.
|
|
101
205
|
#
|
|
102
206
|
# @param target [Bolt::Target] Used for platform detection
|
|
103
|
-
# @param str [
|
|
207
|
+
# @param str [Object] The value to escape, turned into a string first
|
|
104
208
|
# @return [String] Escaped string (single-quoted on Windows, sh-escaped on POSIX)
|
|
105
209
|
def escape_arg(target, str)
|
|
106
|
-
windows_target?(target) ? "'#{ps_escape(str)}'" : Shellwords.shellescape(str)
|
|
210
|
+
windows_target?(target) ? "'#{ps_escape(str.to_s)}'" : Shellwords.shellescape(str)
|
|
107
211
|
end
|
|
108
212
|
|
|
109
213
|
# Join path segments using the target platform's separator.
|
|
@@ -132,9 +236,22 @@ module Bolt
|
|
|
132
236
|
# Escape single quotes for use inside PowerShell single-quoted strings.
|
|
133
237
|
#
|
|
134
238
|
# @param str [String] String to escape
|
|
135
|
-
# @return [String] String with single
|
|
239
|
+
# @return [String] String with every single quote character doubled
|
|
136
240
|
def ps_escape(str)
|
|
137
|
-
|
|
241
|
+
Bolt::Util.escape_powershell_single_quotes(str)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Build the command that runs an executable, through the interpreter
|
|
245
|
+
# when one is given. On Windows it starts with the call operator,
|
|
246
|
+
# since PowerShell treats a quoted path as a string.
|
|
247
|
+
#
|
|
248
|
+
# @param target [Bolt::Target] Used for platform detection
|
|
249
|
+
# @param path [String] Absolute path to the executable on the remote node
|
|
250
|
+
# @param interpreter [String, Array<String>, nil] Interpreter command, or nil to run the file directly
|
|
251
|
+
# @return [String] Shell command
|
|
252
|
+
def executable_command(target, path, interpreter)
|
|
253
|
+
command = (Array(interpreter) + [path]).map { |part| escape_arg(target, part) }.join(' ')
|
|
254
|
+
windows_target?(target) ? "& #{command}" : command
|
|
138
255
|
end
|
|
139
256
|
|
|
140
257
|
# Build the full command string for task execution via the shell agent,
|
|
@@ -144,14 +261,19 @@ module Bolt
|
|
|
144
261
|
# @param target [Bolt::Target] Target (used for platform detection)
|
|
145
262
|
# @param remote_task_path [String] Absolute path to the task executable on the remote node
|
|
146
263
|
# @param arguments [Hash] Task parameter names to values
|
|
147
|
-
# @param input_method [String] How to pass arguments: 'stdin', 'environment', or '
|
|
264
|
+
# @param input_method [String] How to pass arguments: 'stdin', 'environment', 'both', or 'powershell'
|
|
148
265
|
# @param interpreter_options [Hash{String => String}] File extension to interpreter path mapping
|
|
149
266
|
# @return [String] The fully constructed shell command
|
|
150
267
|
def build_task_command(target, remote_task_path, arguments, input_method, interpreter_options)
|
|
268
|
+
if input_method == 'powershell' && windows_target?(target) && File.extname(remote_task_path).casecmp?('.ps1')
|
|
269
|
+
# The arguments become the script's named parameters, like they
|
|
270
|
+
# do with the WinRM transport
|
|
271
|
+
return Bolt::Shell::Powershell::Snippets.json_functions +
|
|
272
|
+
Bolt::Shell::Powershell::Snippets.ps_task(remote_task_path, arguments)
|
|
273
|
+
end
|
|
274
|
+
|
|
151
275
|
interpreter = select_interpreter(remote_task_path, interpreter_options)
|
|
152
|
-
cmd = interpreter
|
|
153
|
-
"#{Array(interpreter).map { |part| escape_arg(target, part) }.join(' ')} #{escape_arg(target, remote_task_path)}" :
|
|
154
|
-
escape_arg(target, remote_task_path)
|
|
276
|
+
cmd = executable_command(target, remote_task_path, interpreter)
|
|
155
277
|
|
|
156
278
|
needs_env = Bolt::Task::ENVIRONMENT_METHODS.include?(input_method)
|
|
157
279
|
needs_stdin = Bolt::Task::STDIN_METHODS.include?(input_method)
|
|
@@ -161,11 +283,9 @@ module Bolt
|
|
|
161
283
|
# requires a script block. Pipeline data doesn't automatically
|
|
162
284
|
# flow through a script block to inner commands, so we
|
|
163
285
|
# explicitly forward $input via a pipe.
|
|
164
|
-
|
|
165
|
-
env_params.each_key { |key| validate_env_key!(key, 'task argument') }
|
|
166
|
-
set_stmts = env_params.map { |key, val| "$env:#{key} = '#{ps_escape(val)}'" }
|
|
286
|
+
env_statements = powershell_env_statements(envify_params(arguments), 'task argument')
|
|
167
287
|
cmd = stdin_pipe_command(target, arguments.to_json,
|
|
168
|
-
"{ #{
|
|
288
|
+
"& { #{env_statements}; $input | #{cmd} }")
|
|
169
289
|
else
|
|
170
290
|
if needs_env
|
|
171
291
|
cmd = prepend_env_vars(target, cmd, envify_params(arguments), 'task argument')
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bolt
|
|
4
|
+
module Transport
|
|
5
|
+
class Choria
|
|
6
|
+
# Upload and download through the file_transfer agent. The transfers
|
|
7
|
+
# themselves are done by the client library the agent's Puppet module
|
|
8
|
+
# installs on the controller, MCollective::Util::FileTransfer. This file
|
|
9
|
+
# maps targets, options and results onto it.
|
|
10
|
+
|
|
11
|
+
# The library's outcome kinds as this transport's error kinds. A kind
|
|
12
|
+
# a later library adds is reported as a transfer failure.
|
|
13
|
+
FILE_TRANSFER_ERROR_KINDS = {
|
|
14
|
+
no_response: 'bolt/choria-no-response',
|
|
15
|
+
rpc_error: 'bolt/choria-rpc-error',
|
|
16
|
+
rpc_failed: 'bolt/choria-rpc-failed',
|
|
17
|
+
transfer_failed: 'bolt/choria-file-transfer-failed',
|
|
18
|
+
payload_too_large: 'bolt/choria-payload-too-large',
|
|
19
|
+
checksum_mismatch: 'bolt/choria-checksum-mismatch'
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
# Upload one file to the capable targets through the library.
|
|
23
|
+
def batch_upload(targets, source, destination, _options = {}, position = [], &callback)
|
|
24
|
+
result_opts = { action: 'upload', name: source, position: position }
|
|
25
|
+
capable, results = prepare_targets(targets, 'file_transfer', result_opts, &callback)
|
|
26
|
+
return results if capable.empty?
|
|
27
|
+
|
|
28
|
+
logger.debug { "Uploading #{source} to #{destination} on #{target_count(capable)}" }
|
|
29
|
+
results + transfer(capable, result_opts, callback) do |client|
|
|
30
|
+
outcomes = client.upload(source, destination, identities_of(capable))
|
|
31
|
+
file_transfer_results(capable, outcomes, result_opts, callback) do |target|
|
|
32
|
+
Bolt::Result.for_upload(target, source, destination)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Download a path from each capable target into its own directory.
|
|
38
|
+
def batch_download(targets, source, destination, _options = {}, position = [], &callback)
|
|
39
|
+
directories = targets.to_h { |target| [target, download_dir(destination, target)] }
|
|
40
|
+
download_into(directories, source, position, &callback)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Download a path from each capable target into the directory given
|
|
44
|
+
# for it.
|
|
45
|
+
#
|
|
46
|
+
# @param directories [Hash{Bolt::Target => String}] Each target and the local directory its download goes into
|
|
47
|
+
# @param source [String] The path to download from each target
|
|
48
|
+
# @param position [Array] Positional info for result tracking
|
|
49
|
+
# @param callback [Proc] Called with :node_start and :node_result events
|
|
50
|
+
# @return [Array<Bolt::Result>]
|
|
51
|
+
def download_into(directories, source, position, &callback)
|
|
52
|
+
result_opts = { action: 'download', name: source, position: position }
|
|
53
|
+
capable, results = prepare_targets(directories.keys, 'file_transfer', result_opts, &callback)
|
|
54
|
+
return results if capable.empty?
|
|
55
|
+
|
|
56
|
+
logger.debug { "Downloading #{source} from #{target_count(capable)}" }
|
|
57
|
+
# The library takes one directory per identity, so targets behind one
|
|
58
|
+
# identity share the last one's directory.
|
|
59
|
+
destination_dirs = capable.to_h { |target| [choria_identity(target), directories.fetch(target)] }
|
|
60
|
+
results + transfer(capable, result_opts, callback) do |client|
|
|
61
|
+
outcomes = client.download(source, destination_dirs)
|
|
62
|
+
file_transfer_results(capable, outcomes, result_opts, callback) do |target, outcome|
|
|
63
|
+
Bolt::Result.for_download(target, source, destination_dirs.fetch(choria_identity(target)), outcome.path)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Runs one transfer for the targets with a library client and returns a
|
|
69
|
+
# result per target. A library that isn't installed on this host, or an
|
|
70
|
+
# error raised for what the controller contains, such as a source that
|
|
71
|
+
# can't be read, is a result for each of the targets.
|
|
72
|
+
#
|
|
73
|
+
# @param targets [Array<Bolt::Target>] The capable targets of one batch
|
|
74
|
+
# @param result_opts [Hash] Options passed through to emit_results (:action, :name, :position)
|
|
75
|
+
# @param callback [Proc, nil] Called with :node_result events
|
|
76
|
+
# @yield [MCollective::Util::FileTransfer::Client] The client for the targets
|
|
77
|
+
# @return [Array<Bolt::Result>]
|
|
78
|
+
def transfer(targets, result_opts, callback)
|
|
79
|
+
yield file_transfer_client(targets)
|
|
80
|
+
rescue Bolt::Error => e
|
|
81
|
+
failures = targets.to_h { |target| [target, error_output(e.message, e.kind)] }
|
|
82
|
+
emit_results(failures, **result_opts, &callback)
|
|
83
|
+
rescue SystemCallError, ArgumentError => e
|
|
84
|
+
logger.warn { "The file transfer raised #{e.class} on the controller: #{e.message}" }
|
|
85
|
+
logger.debug { e.backtrace.join("\n") }
|
|
86
|
+
message = "#{e.message} (#{e.class} on the controller)"
|
|
87
|
+
failures = targets.to_h { |target| [target, error_output(message, FILE_TRANSFER_ERROR_KINDS[:transfer_failed])] }
|
|
88
|
+
emit_results(failures, **result_opts, &callback)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# A library client for the targets, with the batch's options. The
|
|
92
|
+
# library builds its connection from the options this transport's own
|
|
93
|
+
# clients get and the RPC mutex every other call in the process takes.
|
|
94
|
+
# A timeout or size the batch does not set is left to the library's
|
|
95
|
+
# default.
|
|
96
|
+
def file_transfer_client(targets)
|
|
97
|
+
load_file_transfer_library
|
|
98
|
+
options = targets.first.options
|
|
99
|
+
settings = { rpc_timeout: options['file-transfer-timeout'], message_data_size: options['message-data-size'],
|
|
100
|
+
upload_batch_size: options['upload-batch-size'] }
|
|
101
|
+
MCollective::Util::FileTransfer::Client.new(client_options(targets), mutex: @rpc_mutex, logger: logger, **settings.compact)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# The library is a client file of the agent's Puppet module under the
|
|
105
|
+
# libdir the Choria client config names, which loading that config in
|
|
106
|
+
# configure_client put on the load path.
|
|
107
|
+
def load_file_transfer_library
|
|
108
|
+
require 'mcollective/util/file_transfer'
|
|
109
|
+
rescue LoadError => e
|
|
110
|
+
raise Bolt::Error.new(
|
|
111
|
+
'The file_transfer client library is not installed on this host. Apply the mcollective_agent_file_transfer ' \
|
|
112
|
+
"module with client => true where OpenBolt runs, which installs it under the Choria libdir (#{e.class}: #{e.message}).",
|
|
113
|
+
'bolt/choria-file-transfer-library'
|
|
114
|
+
)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# A result per target from the outcomes by identity, firing
|
|
118
|
+
# node_result for each. The block builds the result of a success.
|
|
119
|
+
def file_transfer_results(targets, outcomes, result_opts, callback)
|
|
120
|
+
targets.map do |target|
|
|
121
|
+
outcome = outcomes.fetch(choria_identity(target))
|
|
122
|
+
result = if outcome.success?
|
|
123
|
+
yield(target, outcome)
|
|
124
|
+
else
|
|
125
|
+
kind = FILE_TRANSFER_ERROR_KINDS.fetch(outcome.error_kind, FILE_TRANSFER_ERROR_KINDS[:transfer_failed])
|
|
126
|
+
build_result(target, error_output(outcome.error_message, kind), **result_opts)
|
|
127
|
+
end
|
|
128
|
+
callback&.call(type: :node_result, result: result)
|
|
129
|
+
result
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|