openbolt 5.3.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.
- checksums.yaml +4 -4
- data/Puppetfile +6 -7
- data/lib/bolt/bolt_option_parser.rb +63 -1
- data/lib/bolt/cli.rb +1 -1
- data/lib/bolt/config/options.rb +14 -0
- data/lib/bolt/config/transport/choria.rb +74 -0
- data/lib/bolt/config/transport/options.rb +108 -0
- data/lib/bolt/executor.rb +2 -0
- data/lib/bolt/pal/yaml_plan/transpiler.rb +1 -1
- data/lib/bolt/plugin/puppetdb.rb +1 -1
- data/lib/bolt/plugin.rb +1 -4
- data/lib/bolt/puppetdb/config.rb +8 -0
- data/lib/bolt/puppetdb/instance.rb +1 -0
- data/lib/bolt/result_set.rb +1 -1
- data/lib/bolt/transport/choria/agent_discovery.rb +137 -0
- data/lib/bolt/transport/choria/bolt_tasks.rb +248 -0
- data/lib/bolt/transport/choria/client.rb +281 -0
- data/lib/bolt/transport/choria/command_builders.rb +199 -0
- data/lib/bolt/transport/choria/helpers.rb +197 -0
- data/lib/bolt/transport/choria/shell.rb +560 -0
- data/lib/bolt/transport/choria.rb +218 -0
- data/lib/bolt/transport/winrm/connection.rb +13 -3
- data/lib/bolt/version.rb +1 -1
- data/lib/mcollective/agent/shell.ddl +154 -0
- metadata +35 -14
- data/lib/bolt/plugin/puppet_connect_data.rb +0 -85
- data/modules/puppet_connect/plans/test_input_data.pp +0 -94
|
@@ -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
|