openbolt 5.4.0 → 5.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bolt
4
+ module Transport
5
+ class Choria
6
+ # Platform-aware command builders. These generate the right shell
7
+ # commands based on whether the target is Windows (PowerShell) or
8
+ # POSIX (sh). OS is detected during agent discovery via the
9
+ # os.family fact.
10
+
11
+ # Build a mkdir command for one or more directories.
12
+ #
13
+ # @param target [Bolt::Target] Used for platform detection
14
+ # @param paths [Array<String>] Absolute directory paths to create
15
+ # @return [String] Shell command
16
+ def make_dir_command(target, *paths)
17
+ if windows_target?(target)
18
+ escaped = paths.map { |path| "'#{ps_escape(path)}'" }.join(', ')
19
+ "New-Item -ItemType Directory -Force -Path #{escaped}"
20
+ else
21
+ escaped = paths.map { |path| Shellwords.shellescape(path) }.join(' ')
22
+ "mkdir -m 700 -p #{escaped}"
23
+ end
24
+ end
25
+
26
+ # Build a chmod +x command. Returns nil on Windows (not needed).
27
+ #
28
+ # @param target [Bolt::Target] Used for platform detection
29
+ # @param path [String] Absolute path to the file
30
+ # @return [String, nil] Shell command or nil
31
+ def make_executable_command(target, path)
32
+ windows_target?(target) ? nil : "chmod u+x #{Shellwords.shellescape(path)}"
33
+ end
34
+
35
+ # Build a recursive directory removal command.
36
+ #
37
+ # @param target [Bolt::Target] Used for platform detection
38
+ # @param path [String] Absolute path to the directory
39
+ # @return [String] Shell command
40
+ def cleanup_dir_command(target, path)
41
+ windows_target?(target) ?
42
+ "Remove-Item -Recurse -Force -Path '#{ps_escape(path)}'" :
43
+ "rm -rf #{Shellwords.shellescape(path)}"
44
+ end
45
+
46
+ # Build a command that writes base64-encoded content to a file
47
+ # after decoding the content. Requires base64 CLI on POSIX targets.
48
+ #
49
+ # @param target [Bolt::Target] Used for platform detection
50
+ # @param content_b64 [String] Base64-encoded file content
51
+ # @param dest [String] Absolute destination path on the remote node
52
+ # @return [String] Shell command
53
+ def upload_file_command(target, content_b64, dest)
54
+ if windows_target?(target)
55
+ "[IO.File]::WriteAllBytes('#{ps_escape(dest)}', " \
56
+ "[Convert]::FromBase64String('#{content_b64}'))"
57
+ else
58
+ "printf '%s' #{Shellwords.shellescape(content_b64)} | base64 -d > #{Shellwords.shellescape(dest)}"
59
+ end
60
+ end
61
+
62
+ # Prepend environment variables to a command string.
63
+ # Returns the command unchanged if env_vars is nil or empty.
64
+ #
65
+ # @param target [Bolt::Target] Used for platform detection
66
+ # @param command [String] The command to prepend env vars to
67
+ # @param env_vars [Hash{String => String}, nil] Variable names to values
68
+ # @param context [String] Description for error messages (e.g., 'task argument')
69
+ # @return [String] Command with env vars prepended
70
+ def prepend_env_vars(target, command, env_vars, context)
71
+ return command unless env_vars&.any?
72
+
73
+ env_vars.each_key { |key| validate_env_key!(key, context) }
74
+
75
+ if windows_target?(target)
76
+ set_stmts = env_vars.map { |key, val| "$env:#{key} = '#{ps_escape(val)}'" }
77
+ "#{set_stmts.join('; ')}; & #{command}"
78
+ else
79
+ env_str = env_vars.map { |key, val| "#{key}=#{Shellwords.shellescape(val)}" }.join(' ')
80
+ "/usr/bin/env #{env_str} #{command}"
81
+ end
82
+ end
83
+
84
+ # Build a command that pipes data to another command via stdin.
85
+ #
86
+ # @param target [Bolt::Target] Used for platform detection
87
+ # @param data [String] Data to pipe (typically JSON task arguments)
88
+ # @param command [String] The command to receive stdin
89
+ # @return [String] Shell command with stdin piping
90
+ def stdin_pipe_command(target, data, command)
91
+ if windows_target?(target)
92
+ # Use a here-string (@'...'@) to avoid escaping issues with
93
+ # large JSON payloads. Content between @' and '@ is literal.
94
+ "@'\n#{data}\n'@ | & #{command}"
95
+ else
96
+ "printf '%s' #{Shellwords.shellescape(data)} | #{command}"
97
+ end
98
+ end
99
+
100
+ # Escape a string for use as a shell argument on the target platform.
101
+ #
102
+ # @param target [Bolt::Target] Used for platform detection
103
+ # @param str [String] The string to escape
104
+ # @return [String] Escaped string (single-quoted on Windows, sh-escaped on POSIX)
105
+ def escape_arg(target, str)
106
+ windows_target?(target) ? "'#{ps_escape(str)}'" : Shellwords.shellescape(str)
107
+ end
108
+
109
+ # Join path segments using the target platform's separator.
110
+ # Normalizes embedded forward slashes to backslashes on Windows.
111
+ #
112
+ # @param target [Bolt::Target] Used for platform detection
113
+ # @param parts [Array<String>] Path segments to join
114
+ # @return [String] Joined path
115
+ def join_path(target, *parts)
116
+ sep = windows_target?(target) ? '\\' : '/'
117
+ parts = parts.map { |part| part.tr('/', sep) } if sep != '/'
118
+ parts.join(sep)
119
+ end
120
+
121
+ # Wrap a PowerShell script for execution via shell agent. Uses
122
+ # -EncodedCommand with Base64-encoded UTF-16LE (the encoding
123
+ # Microsoft requires for -EncodedCommand) to avoid all quoting
124
+ # issues with cmd.exe and PowerShell metacharacters.
125
+ #
126
+ # @param script [String] PowerShell script to encode and wrap
127
+ # @return [String] powershell.exe command with -EncodedCommand
128
+ def powershell_cmd(script)
129
+ "powershell.exe -NoProfile -NonInteractive -EncodedCommand #{Base64.strict_encode64(script.encode('UTF-16LE'))}"
130
+ end
131
+
132
+ # Escape single quotes for use inside PowerShell single-quoted strings.
133
+ #
134
+ # @param str [String] String to escape
135
+ # @return [String] String with single quotes doubled
136
+ def ps_escape(str)
137
+ str.gsub("'", "''")
138
+ end
139
+
140
+ # Build the full command string for task execution via the shell agent,
141
+ # handling interpreter selection, environment variable injection, and
142
+ # stdin piping.
143
+ #
144
+ # @param target [Bolt::Target] Target (used for platform detection)
145
+ # @param remote_task_path [String] Absolute path to the task executable on the remote node
146
+ # @param arguments [Hash] Task parameter names to values
147
+ # @param input_method [String] How to pass arguments: 'stdin', 'environment', or 'both'
148
+ # @param interpreter_options [Hash{String => String}] File extension to interpreter path mapping
149
+ # @return [String] The fully constructed shell command
150
+ def build_task_command(target, remote_task_path, arguments, input_method, interpreter_options)
151
+ 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)
155
+
156
+ needs_env = Bolt::Task::ENVIRONMENT_METHODS.include?(input_method)
157
+ needs_stdin = Bolt::Task::STDIN_METHODS.include?(input_method)
158
+
159
+ if needs_env && needs_stdin && windows_target?(target)
160
+ # On Windows, piping stdin into a multi-statement command
161
+ # requires a script block. Pipeline data doesn't automatically
162
+ # flow through a script block to inner commands, so we
163
+ # 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)}'" }
167
+ cmd = stdin_pipe_command(target, arguments.to_json,
168
+ "{ #{set_stmts.join('; ')}; $input | & #{cmd} }")
169
+ else
170
+ if needs_env
171
+ cmd = prepend_env_vars(target, cmd, envify_params(arguments), 'task argument')
172
+ end
173
+
174
+ if needs_stdin
175
+ cmd = stdin_pipe_command(target, arguments.to_json, cmd)
176
+ end
177
+ end
178
+
179
+ cmd
180
+ end
181
+
182
+ # Convert task arguments to PT_-prefixed environment variable hash.
183
+ # Duplicated from Bolt::Shell#envify_params. We don't use Bolt::Shell
184
+ # classes because they interleave command building with connection-based
185
+ # execution (IO pipes, sudo prompts). With the Choria transport, we just
186
+ # need to build the command and send it via RPC so all the shell agents
187
+ # on the targets can execute it themselves.
188
+ #
189
+ # @param params [Hash{String => Object}] Task parameter names to values
190
+ # @return [Hash{String => String}] Environment variables with PT_ prefix
191
+ def envify_params(params)
192
+ params.each_with_object({}) do |(key, val), env|
193
+ val = val.to_json unless val.is_a?(String)
194
+ env["PT_#{key}"] = val
195
+ end
196
+ end
197
+ end
198
+ end
199
+ end
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bolt
4
+ module Transport
5
+ class Choria
6
+ # Polling interval between rounds, used by poll_task_status
7
+ # and wait_for_shell_results. Each round makes one batched RPC call
8
+ # regardless of target count, so a 1-second interval balances
9
+ # responsiveness against broker load.
10
+ POLL_INTERVAL_SECONDS = 1
11
+
12
+ # Matches Windows absolute paths like C:\temp or D:/foo.
13
+ # Used by validate_file_name! and Config::Transport::Choria#absolute_path?.
14
+ WINDOWS_PATH_REGEX = %r{\A[A-Za-z]:[\\/]}
15
+
16
+ def target_count(targets)
17
+ count = targets.is_a?(Hash) ? targets.size : targets.length
18
+ "#{count} #{count == 1 ? 'target' : 'targets'}"
19
+ end
20
+
21
+ # Shared polling loop for bolt_tasks and shell polling. Handles sleep
22
+ # timing, round counting, RPC failure retry, and deadline enforcement.
23
+ #
24
+ # The block receives the remaining targets each round and returns:
25
+ # { done: {target => output_hash}, rpc_failed: bool }
26
+ #
27
+ # @param targets [Array, Hash] Initial targets to poll (duped internally)
28
+ # @param timeout [Numeric] Maximum seconds before exiting
29
+ # @param context [String] Label for log messages
30
+ # @return [Hash] with keys:
31
+ # - :completed [Hash{Target => Hash}] All finished target outputs
32
+ # - :remaining [Array, Hash] Targets still pending when the loop exited
33
+ # - :rpc_persistent_failure [Boolean] True if loop exited due to persistent RPC failures
34
+ def poll_with_retries(targets, timeout, context)
35
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
36
+ remaining = targets.dup
37
+ completed = {}
38
+ poll_failures = 0
39
+ poll_round = 0
40
+
41
+ until remaining.empty?
42
+ sleep(POLL_INTERVAL_SECONDS)
43
+ poll_round += 1
44
+ logger.debug { "Poll round #{poll_round}: #{target_count(remaining)} still pending" }
45
+
46
+ round = yield(remaining)
47
+
48
+ if round[:rpc_failed]
49
+ poll_failures += 1
50
+ logger.warn { "#{context} poll failed (attempt #{poll_failures}/#{RPC_FAILURE_RETRIES})" }
51
+ break if poll_failures >= RPC_FAILURE_RETRIES
52
+
53
+ next
54
+ end
55
+ poll_failures = 0
56
+
57
+ round[:done].each do |target, output|
58
+ completed[target] = output
59
+ remaining.delete(target)
60
+ end
61
+
62
+ break if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
63
+ end
64
+
65
+ { completed: completed, remaining: remaining,
66
+ rpc_persistent_failure: poll_failures >= RPC_FAILURE_RETRIES }
67
+ end
68
+
69
+ # Build a Bolt::Result from an output hash. Handles both success and
70
+ # error cases based on the presence of the :error key.
71
+ #
72
+ # @param target [Bolt::Target] The target this result belongs to
73
+ # @param data [Hash] Output hash with keys :stdout, :stderr, :exitcode, and
74
+ # optionally :error and :error_kind for failures
75
+ # @param action [String] One of 'task', 'command', or 'script'
76
+ # @param name [String] Task/command/script name for result metadata
77
+ # @param position [Array] Positional info for result tracking
78
+ # @return [Bolt::Result] The constructed result
79
+ def build_result(target, data, action:, name:, position:)
80
+ if data[:error]
81
+ Bolt::Result.from_exception(
82
+ target, Bolt::Error.new(data[:error], data[:error_kind]),
83
+ action: action, position: position
84
+ )
85
+ elsif action == 'task'
86
+ Bolt::Result.for_task(target, data[:stdout], data[:stderr],
87
+ data[:exitcode], name, position)
88
+ elsif %w[command script].include?(action)
89
+ Bolt::Result.for_command(
90
+ target,
91
+ { 'stdout' => data[:stdout], 'stderr' => data[:stderr], 'exit_code' => data[:exitcode] },
92
+ action, name, position
93
+ )
94
+ else
95
+ raise Bolt::Error.new(
96
+ "Unknown action '#{action}' in build_result",
97
+ 'bolt/choria-unknown-action'
98
+ )
99
+ end
100
+ end
101
+
102
+ # Convert a hash of { target => output } into Results, fire callbacks,
103
+ # and return the Results array. When fire_node_start is true, fires a
104
+ # :node_start callback before each :node_result.
105
+ #
106
+ # @param target_outputs [Hash{Bolt::Target => Hash}] Map of targets to output hashes
107
+ # @param action [String] One of 'task', 'command', or 'script'
108
+ # @param name [String] Task/command/script name for result metadata
109
+ # @param position [Array] Positional info for result tracking
110
+ # @param fire_node_start [Boolean] Whether to emit :node_start before each result
111
+ # @param callback [Proc] Called with :node_start and :node_result events
112
+ # @return [Array<Bolt::Result>] Results for all targets in the hash
113
+ def emit_results(target_outputs, action:, name:, position:, fire_node_start: false, &callback)
114
+ target_outputs.map do |target, data|
115
+ callback&.call(type: :node_start, target: target) if fire_node_start
116
+ result = build_result(target, data, action: action, name: name, position: position)
117
+ callback&.call(type: :node_result, result: result)
118
+ result
119
+ end
120
+ end
121
+
122
+ # Build an output hash from command/task output.
123
+ def output(stdout: nil, stderr: nil, exitcode: nil)
124
+ { stdout: stdout || '', stderr: stderr || '', exitcode: exitcode || 0 }
125
+ end
126
+
127
+ # Build an error output hash. When actual output is available (e.g.
128
+ # a command ran but failed), pass it through so the user sees it.
129
+ def error_output(message, kind, stdout: nil, stderr: nil, exitcode: 1)
130
+ output(stdout: stdout, stderr: stderr, exitcode: exitcode)
131
+ .merge(error: message, error_kind: kind)
132
+ end
133
+
134
+ # Extract exit code from RPC response data, defaulting to 1 with a
135
+ # warning if the agent returned nil.
136
+ #
137
+ # @param data [Hash] RPC response data containing :exitcode
138
+ # @param target [Bolt::Target] Target for logging context
139
+ # @param context [String] Human-readable label for the log message
140
+ # @return [Integer] The exit code from the data, or 1 if nil
141
+ def exitcode_from(data, target, context)
142
+ exitcode = data[:exitcode] || data['exitcode']
143
+ if exitcode.nil?
144
+ logger.warn {
145
+ "Agent on #{target.safe_name} returned no exit code for #{context}. " \
146
+ "Defaulting to exit code 1. This usually indicates an agent-level error."
147
+ }
148
+ exitcode = 1
149
+ end
150
+ exitcode
151
+ end
152
+
153
+ # Validate that a file name does not contain path traversal sequences
154
+ # or absolute paths. Checks both POSIX and Windows conventions.
155
+ # Raises Bolt::Error on violations.
156
+ #
157
+ # @param name [String] Task file name to validate
158
+ def validate_file_name!(name)
159
+ if name.include?("\0")
160
+ raise Bolt::Error.new(
161
+ "Invalid null byte in task file name: #{name.inspect}",
162
+ 'bolt/invalid-task-filename'
163
+ )
164
+ end
165
+
166
+ if name.start_with?('/') || name.match?(WINDOWS_PATH_REGEX)
167
+ raise Bolt::Error.new(
168
+ "Absolute path not allowed in task file name: '#{name}'",
169
+ 'bolt/invalid-task-filename'
170
+ )
171
+ end
172
+
173
+ if name.split(%r{[/\\]}).include?('..')
174
+ raise Bolt::Error.new(
175
+ "Path traversal detected in task file name: '#{name}'",
176
+ 'bolt/path-traversal'
177
+ )
178
+ end
179
+ end
180
+
181
+ # Validate an environment variable key is safe for shell interpolation.
182
+ #
183
+ # @param key [String] Environment variable name to validate
184
+ # @param context [String] Description for error messages
185
+ def validate_env_key!(key, context)
186
+ safe_pattern = /\A[A-Za-z_][A-Za-z0-9_]*\z/
187
+ return if safe_pattern.match?(key)
188
+
189
+ raise Bolt::Error.new(
190
+ "Unsafe environment variable name '#{key}' in #{context}. " \
191
+ "Names must match #{safe_pattern.source}",
192
+ 'bolt/invalid-env-var-name'
193
+ )
194
+ end
195
+ end
196
+ end
197
+ end