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
@@ -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'] && opts['ssl-cert'] && opts['ssl-key']
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
- # Accepts a single target or an array. Uses MCollective's direct
105
- # addressing mode (client.discover(nodes:)) to skip broadcast
106
- # discovery and send requests directly to the specified nodes.
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 [Bolt::Target, Array<Bolt::Target>] One or more targets to address
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
- targets = Array(targets)
121
- options = MCollective::Util.default_options
122
- options[:timeout] = timeout
123
- options[:verbose] = false
124
- options[:connection_timeout] = targets.first.options['broker-timeout']
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
- collective = collective_for(targets.first)
127
- options[:collective] = collective if collective
128
-
129
- client = MCollective::RPC::Client.new(agent_name, options: options)
130
- client.progress = false
131
-
132
- identities = targets.map { |target| choria_identity(target) }.uniq
133
- client.discover(nodes: identities)
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 completed but reported a
144
- # problem (:responded). The data is preserved rather than
145
- # discarded because some agents (notably bolt_tasks) use
146
- # statuscode 1 for application-level failures where the
147
- # response data is still valid and meaningful (e.g., a task
148
- # that ran but exited non-zero). Callers must handle this
149
- # case and not assume :responded means success.
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-1),
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
- # - :rpc_statuscodes [Hash] Per-target MCollective RPC statuscodes.
169
- # Includes all targets that responded (both :responded and :errors).
170
- # Not populated when rpc_failed is true (no individual responses).
171
- def rpc_request(agent, targets, context)
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
- rpc_timeout = targets.first.options['rpc-timeout']
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
- rpc_statuscodes = {}
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, rpc_statuscodes: rpc_statuscodes }
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, rpc_statuscodes: {} }
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
- # Configure the client, discover agents, partition targets by agent
218
- # availability, and emit errors for incapable targets.
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
- msg = if @agent_cache[choria_identity(target)].nil?
235
- "No agent information available for #{target.safe_name} (node did not respond to discovery)"
236
- else
237
- "The '#{agent_name}' agent is not available on #{target.safe_name}."
238
- end
239
- errors[target] = error_output(msg, 'bolt/choria-agent-not-available')
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, fire_node_start: true, **result_opts, &callback)
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 => String}, nil] Variable names to values
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
- set_stmts = env_vars.map { |key, val| "$env:#{key} = '#{ps_escape(val)}'" }
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. Content between @' and '@ is literal.
94
- "@'\n#{data}\n'@ | & #{command}"
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 string for use as a shell argument on the target platform.
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 [String] The string to escape
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 quotes doubled
239
+ # @return [String] String with every single quote character doubled
136
240
  def ps_escape(str)
137
- str.gsub("'", "''")
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 'both'
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
- env_params = envify_params(arguments)
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
- "{ #{set_stmts.join('; ')}; $input | & #{cmd} }")
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