smart_proxy_openbolt 0.1.1 → 1.2.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/README.md +297 -11
- data/lib/smart_proxy_openbolt/api.rb +20 -41
- data/lib/smart_proxy_openbolt/config/choria-client.conf +25 -0
- data/lib/smart_proxy_openbolt/error.rb +17 -27
- data/lib/smart_proxy_openbolt/executor.rb +32 -26
- data/lib/smart_proxy_openbolt/job.rb +17 -81
- data/lib/smart_proxy_openbolt/lru_cache.rb +43 -0
- data/lib/smart_proxy_openbolt/main.rb +293 -106
- data/lib/smart_proxy_openbolt/plugin.rb +7 -9
- data/lib/smart_proxy_openbolt/result.rb +20 -23
- data/lib/smart_proxy_openbolt/task_job.rb +36 -41
- data/lib/smart_proxy_openbolt/version.rb +1 -1
- data/settings.d/openbolt.yml +1 -0
- metadata +6 -3
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
require 'json'
|
|
2
2
|
require 'open3'
|
|
3
|
+
require 'openssl'
|
|
3
4
|
require 'smart_proxy_openbolt/executor'
|
|
4
5
|
require 'smart_proxy_openbolt/error'
|
|
5
|
-
require 'thread'
|
|
6
6
|
|
|
7
7
|
module Proxy::OpenBolt
|
|
8
8
|
extend ::Proxy::Util
|
|
9
9
|
extend ::Proxy::Log
|
|
10
10
|
|
|
11
|
-
TRANSPORTS = ['ssh', 'winrm']
|
|
11
|
+
TRANSPORTS = ['ssh', 'winrm', 'choria'].freeze
|
|
12
12
|
# The key should be exactly the flag name passed to OpenBolt
|
|
13
13
|
# Type must be :boolean, :string, or an array of acceptable string values
|
|
14
14
|
# Transport must be an array of transport types it applies to. This is
|
|
@@ -26,25 +26,25 @@ module Proxy::OpenBolt
|
|
|
26
26
|
},
|
|
27
27
|
'log-level' => {
|
|
28
28
|
:type => ['error', 'warning', 'info', 'debug', 'trace'],
|
|
29
|
-
:transport => ['ssh', 'winrm'],
|
|
29
|
+
:transport => ['ssh', 'winrm', 'choria'],
|
|
30
30
|
:sensitive => false,
|
|
31
31
|
:description => 'Set the log level during OpenBolt execution.',
|
|
32
32
|
},
|
|
33
33
|
'verbose' => {
|
|
34
34
|
:type => :boolean,
|
|
35
|
-
:transport => ['ssh', 'winrm'],
|
|
35
|
+
:transport => ['ssh', 'winrm', 'choria'],
|
|
36
36
|
:sensitive => false,
|
|
37
37
|
:description => 'Run the OpenBolt command with the --verbose flag. This prints additional information during OpenBolt execution and will print any out::verbose plan statements.',
|
|
38
38
|
},
|
|
39
39
|
'noop' => {
|
|
40
40
|
:type => :boolean,
|
|
41
|
-
:transport => ['ssh', 'winrm'],
|
|
41
|
+
:transport => ['ssh', 'winrm', 'choria'],
|
|
42
42
|
:sensitive => false,
|
|
43
43
|
:description => 'Run the OpenBolt command with the --noop flag, which will make no changes to the target host.',
|
|
44
44
|
},
|
|
45
45
|
'tmpdir' => {
|
|
46
46
|
:type => :string,
|
|
47
|
-
:transport => ['ssh', 'winrm'],
|
|
47
|
+
:transport => ['ssh', 'winrm', 'choria'],
|
|
48
48
|
:sensitive => false,
|
|
49
49
|
:description => 'Directory to use for temporary files on target hosts during OpenBolt execution.',
|
|
50
50
|
},
|
|
@@ -96,16 +96,105 @@ module Proxy::OpenBolt
|
|
|
96
96
|
:sensitive => false,
|
|
97
97
|
:description => 'Verify remote host SSL certificate when connecting to hosts via WinRM.',
|
|
98
98
|
},
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
99
|
+
'choria-task-agent' => {
|
|
100
|
+
:type => ['bolt_tasks', 'shell'],
|
|
101
|
+
:transport => ['choria'],
|
|
102
|
+
:sensitive => false,
|
|
103
|
+
:description => 'Choria agent used to execute tasks on target nodes. "bolt_tasks" runs tasks via the Choria bolt_tasks agent and "shell" runs them via the shell agent. Defaults to "bolt_tasks" when not specified.',
|
|
104
|
+
},
|
|
105
|
+
'choria-config-file' => {
|
|
106
|
+
:type => :string,
|
|
107
|
+
:transport => ['choria'],
|
|
108
|
+
:sensitive => false,
|
|
109
|
+
:description => 'Path on the smart proxy host to the Choria client configuration file. This file must be readable by the foreman-proxy user. When blank, the proxy uses a built-in default.',
|
|
110
|
+
},
|
|
111
|
+
'choria-mcollective-certname' => {
|
|
112
|
+
:type => :string,
|
|
113
|
+
:transport => ['choria'],
|
|
114
|
+
:sensitive => false,
|
|
115
|
+
:description => 'Override the MCollective certname for Choria client identity. When blank, the proxy derives this automatically from the SSL certificate.',
|
|
116
|
+
},
|
|
117
|
+
'choria-ssl-ca' => {
|
|
118
|
+
:type => :string,
|
|
119
|
+
:transport => ['choria'],
|
|
120
|
+
:sensitive => false,
|
|
121
|
+
:description => 'Path on the smart proxy host to the CA certificate used to verify Choria brokers and peers. This file must be readable by the foreman-proxy user. Must be provided together with choria-ssl-cert and choria-ssl-key.',
|
|
122
|
+
},
|
|
123
|
+
'choria-ssl-cert' => {
|
|
124
|
+
:type => :string,
|
|
125
|
+
:transport => ['choria'],
|
|
126
|
+
:sensitive => false,
|
|
127
|
+
:description => 'Path on the smart proxy host to the client SSL certificate used to authenticate with Choria. This file must be readable by the foreman-proxy user. Must be provided together with choria-ssl-ca and choria-ssl-key.',
|
|
128
|
+
},
|
|
129
|
+
'choria-ssl-key' => {
|
|
130
|
+
:type => :string,
|
|
131
|
+
:transport => ['choria'],
|
|
132
|
+
:sensitive => false,
|
|
133
|
+
:description => 'Path on the smart proxy host to the client SSL private key used to authenticate with Choria. This key must be readable by the foreman-proxy user. Must be provided together with choria-ssl-ca and choria-ssl-cert.',
|
|
134
|
+
},
|
|
135
|
+
'choria-collective' => {
|
|
136
|
+
:type => :string,
|
|
137
|
+
:transport => ['choria'],
|
|
138
|
+
:sensitive => false,
|
|
139
|
+
:description => 'Choria collective to route messages through.',
|
|
140
|
+
},
|
|
141
|
+
'choria-puppet-environment' => {
|
|
142
|
+
:type => :string,
|
|
143
|
+
:transport => ['choria'],
|
|
144
|
+
:sensitive => false,
|
|
145
|
+
:description => 'Puppet environment reported to the Choria agent when executing tasks. Defaults to "production" when not specified. Typically matches the proxy\'s environment_path setting.',
|
|
146
|
+
},
|
|
147
|
+
'choria-rpc-timeout' => {
|
|
148
|
+
:type => :string,
|
|
149
|
+
:transport => ['choria'],
|
|
150
|
+
:sensitive => false,
|
|
151
|
+
:description => 'Timeout in seconds for individual Choria RPC calls. Defaults to 30 when not specified.',
|
|
152
|
+
},
|
|
153
|
+
'choria-task-timeout' => {
|
|
154
|
+
:type => :string,
|
|
155
|
+
:transport => ['choria'],
|
|
156
|
+
:sensitive => false,
|
|
157
|
+
:description => 'Timeout in seconds for a Choria task to complete on a target node. Defaults to 300 when not specified.',
|
|
158
|
+
},
|
|
159
|
+
'choria-command-timeout' => {
|
|
160
|
+
:type => :string,
|
|
161
|
+
:transport => ['choria'],
|
|
162
|
+
:sensitive => false,
|
|
163
|
+
:description => 'Timeout in seconds for a Choria shell command to complete on a target node. Defaults to 60 when not specified.',
|
|
164
|
+
},
|
|
165
|
+
'choria-brokers' => {
|
|
166
|
+
:type => :string,
|
|
167
|
+
:transport => ['choria'],
|
|
168
|
+
:sensitive => false,
|
|
169
|
+
:description => 'Comma-separated list of Choria broker addresses in host or host:port format (e.g. broker1:4222,broker2:4222). Port defaults to 4222 if omitted.',
|
|
170
|
+
},
|
|
171
|
+
'choria-broker-timeout' => {
|
|
172
|
+
:type => :string,
|
|
173
|
+
:transport => ['choria'],
|
|
174
|
+
:sensitive => false,
|
|
175
|
+
:description => 'Timeout in seconds for establishing a connection to a Choria broker.',
|
|
176
|
+
},
|
|
177
|
+
}.freeze
|
|
178
|
+
SORTED_OPTIONS = OPENBOLT_OPTIONS.sort.to_h.freeze
|
|
179
|
+
|
|
180
|
+
@mutex = Mutex.new
|
|
102
181
|
|
|
182
|
+
class << self
|
|
103
183
|
def openbolt_options
|
|
104
|
-
|
|
184
|
+
SORTED_OPTIONS
|
|
105
185
|
end
|
|
106
186
|
|
|
107
187
|
def executor
|
|
108
|
-
@executor ||=
|
|
188
|
+
@executor ||= Executor.instance
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def validate_job_id!(id)
|
|
192
|
+
return if /\A[a-f0-9-]+\z/i.match?(id)
|
|
193
|
+
raise Error.new(message: 'Invalid job ID format')
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def result_file_path(id)
|
|
197
|
+
File.join(Plugin.settings.log_dir, "#{id}.json")
|
|
109
198
|
end
|
|
110
199
|
|
|
111
200
|
# /tasks or /tasks/reload
|
|
@@ -113,7 +202,7 @@ module Proxy::OpenBolt
|
|
|
113
202
|
# If we need to reload, only one instance of the reload
|
|
114
203
|
# should happen at once. Make others wait until it is
|
|
115
204
|
# finished.
|
|
116
|
-
|
|
205
|
+
@mutex.synchronize do
|
|
117
206
|
@tasks = nil if reload
|
|
118
207
|
@tasks || reload_tasks
|
|
119
208
|
end
|
|
@@ -123,59 +212,31 @@ module Proxy::OpenBolt
|
|
|
123
212
|
task_data = {}
|
|
124
213
|
|
|
125
214
|
# Get a list of all tasks
|
|
126
|
-
command =
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
command: command,
|
|
135
|
-
)
|
|
136
|
-
end
|
|
137
|
-
task_names = []
|
|
138
|
-
begin
|
|
139
|
-
task_names = JSON.parse(stdout)['tasks'].map { |t| t[0] }
|
|
140
|
-
rescue JSON::ParserError => e
|
|
141
|
-
raise Proxy::OpenBolt::Error.new(
|
|
142
|
-
message: "Error occurred when parsing 'bolt task show' output.",
|
|
143
|
-
exception: e,
|
|
215
|
+
command = ['bolt', 'task', 'show',
|
|
216
|
+
'--project', Plugin.settings.environment_path,
|
|
217
|
+
'--format', 'json']
|
|
218
|
+
parsed = openbolt_json(command)
|
|
219
|
+
task_list = parsed['tasks']
|
|
220
|
+
unless task_list.is_a?(Array)
|
|
221
|
+
raise Error.new(
|
|
222
|
+
message: "Unexpected output from 'bolt task show': expected 'tasks' to be an array, got #{task_list.class}."
|
|
144
223
|
)
|
|
145
224
|
end
|
|
146
225
|
|
|
147
|
-
# Get metadata for each task
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
exitcode: status.exitstatus,
|
|
156
|
-
stdout: stdout,
|
|
157
|
-
stderr: stderr,
|
|
158
|
-
command: command,
|
|
159
|
-
)
|
|
160
|
-
end
|
|
161
|
-
metadata = {}
|
|
162
|
-
begin
|
|
163
|
-
metadata = JSON.parse(stdout)['metadata']
|
|
164
|
-
rescue Json::ParserError => e
|
|
165
|
-
@tasks = nil
|
|
166
|
-
raise Proxy::OpenBolt::Error.new(
|
|
167
|
-
message: "Error occurred when parsing 'bolt task show #{name}' output.",
|
|
168
|
-
exception: e,
|
|
169
|
-
)
|
|
170
|
-
end
|
|
226
|
+
# Get metadata for each task
|
|
227
|
+
task_list.each do |task_entry|
|
|
228
|
+
name = task_entry[0]
|
|
229
|
+
command = ['bolt', 'task', 'show', name,
|
|
230
|
+
'--project', Plugin.settings.environment_path,
|
|
231
|
+
'--format', 'json']
|
|
232
|
+
result = openbolt_json(command)
|
|
233
|
+
metadata = result['metadata']
|
|
171
234
|
if metadata.nil?
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
message: "Invalid metadata found for task #{name}",
|
|
175
|
-
output: output,
|
|
176
|
-
command: command,
|
|
235
|
+
raise Error.new(
|
|
236
|
+
message: "Invalid metadata found for task #{name}"
|
|
177
237
|
)
|
|
178
238
|
end
|
|
239
|
+
|
|
179
240
|
task_data[name] = {
|
|
180
241
|
'description' => metadata['description'] || '',
|
|
181
242
|
'parameters' => metadata['parameters'] || {},
|
|
@@ -203,102 +264,216 @@ module Proxy::OpenBolt
|
|
|
203
264
|
def launch_task(data)
|
|
204
265
|
### Validation ###
|
|
205
266
|
unless data.is_a?(Hash)
|
|
206
|
-
raise
|
|
267
|
+
raise Error.new(message: 'Data passed in to launch_task function is not a hash. This is most likely a bug in the smart_proxy_openbolt plugin. Please file an issue with the maintainers.')
|
|
207
268
|
end
|
|
208
269
|
fields = ['name', 'parameters', 'targets', 'options']
|
|
209
|
-
unless fields.all? { |k| data.
|
|
210
|
-
raise
|
|
270
|
+
unless fields.all? { |k| data.key?(k) }
|
|
271
|
+
raise Error.new(message: "You must provide values for 'name', 'parameters', 'targets', and 'options'.")
|
|
211
272
|
end
|
|
212
273
|
name = data['name']
|
|
213
274
|
params = data['parameters'] || {}
|
|
214
275
|
targets = data['targets']
|
|
215
|
-
options = data['options']
|
|
276
|
+
options = data['options'] || {}
|
|
216
277
|
|
|
217
278
|
logger.info("Task: #{name}")
|
|
218
279
|
logger.info("Parameters: #{params.inspect}")
|
|
219
280
|
logger.info("Targets: #{targets.inspect}")
|
|
220
|
-
logger.info("Options: #{scrub(options, options.inspect
|
|
281
|
+
logger.info("Options: #{scrub(options, options.inspect)}")
|
|
221
282
|
|
|
222
283
|
# Validate name
|
|
223
|
-
raise
|
|
224
|
-
raise
|
|
284
|
+
raise Error.new(message: "You must provide a value for 'name'.") unless name.is_a?(String) && !name.empty?
|
|
285
|
+
raise Error.new(message: "Task #{name} not found.") unless tasks.key?(name)
|
|
225
286
|
|
|
226
287
|
# Validate parameters
|
|
227
|
-
raise
|
|
228
|
-
missing = []
|
|
229
|
-
tasks[name]['parameters'].each do |k, v|
|
|
230
|
-
next if v['type'].start_with?('Optional[')
|
|
231
|
-
missing << k unless params.keys.include?(k)
|
|
232
|
-
end
|
|
233
|
-
raise Proxy::OpenBolt::Error.new(message: "Missing required parameters: #{missing}") unless missing.empty?
|
|
288
|
+
raise Error.new(message: "The 'parameters' value should be a hash.") unless params.is_a?(Hash)
|
|
234
289
|
extra = params.keys - tasks[name]['parameters'].keys
|
|
235
|
-
raise
|
|
290
|
+
raise Error.new(message: "Unknown parameters: #{extra}") unless extra.empty?
|
|
236
291
|
|
|
237
292
|
# Normalize parameters, ensuring blank values are not passed
|
|
238
293
|
params = normalize_values(params)
|
|
239
294
|
logger.info("Normalized parameters: #{params.inspect}")
|
|
240
295
|
|
|
296
|
+
# Check required parameters after normalization so blank values are caught
|
|
297
|
+
missing = []
|
|
298
|
+
tasks[name]['parameters'].each do |k, v|
|
|
299
|
+
next if v['type']&.start_with?('Optional[')
|
|
300
|
+
next if v.key?('default')
|
|
301
|
+
missing << k unless params.key?(k)
|
|
302
|
+
end
|
|
303
|
+
raise Error.new(message: "Missing required parameters: #{missing}") unless missing.empty?
|
|
304
|
+
|
|
241
305
|
# Validate targets
|
|
242
|
-
raise
|
|
243
|
-
|
|
244
|
-
|
|
306
|
+
raise Error.new(message: "The 'targets' value should be a string or an array.") unless targets.is_a?(String) || targets.is_a?(Array)
|
|
307
|
+
if targets.is_a?(Array)
|
|
308
|
+
raise Error.new(message: "All target values must be strings.") unless targets.all?(String)
|
|
309
|
+
targets = targets.map(&:strip).reject(&:empty?)
|
|
310
|
+
else
|
|
311
|
+
targets = targets.split(',').map(&:strip).reject(&:empty?)
|
|
312
|
+
end
|
|
313
|
+
raise Error.new(message: "The 'targets' value should not be empty.") if targets.empty?
|
|
245
314
|
|
|
246
|
-
options ||= {}
|
|
247
315
|
# Validate options
|
|
248
|
-
raise
|
|
249
|
-
extra = options.keys - OPENBOLT_OPTIONS.keys
|
|
250
|
-
raise Proxy::OpenBolt::Error.new(message: "Invalid options specified: #{extra}") unless extra.empty?
|
|
316
|
+
raise Error.new(message: "The 'options' value should be a hash.") unless options.is_a?(Hash)
|
|
251
317
|
unknown = options.keys - OPENBOLT_OPTIONS.keys
|
|
252
|
-
raise
|
|
318
|
+
raise Error.new(message: "Invalid options specified: #{unknown}") unless unknown.empty?
|
|
253
319
|
|
|
254
320
|
# Normalize options, removing blank values
|
|
255
321
|
options = normalize_values(options)
|
|
256
|
-
logger.info("Normalized options: #{scrub(options, options.inspect
|
|
257
|
-
OPENBOLT_OPTIONS.each { |key,
|
|
258
|
-
logger.info("Options with required defaults: #{scrub(options, options.inspect
|
|
322
|
+
logger.info("Normalized options: #{scrub(options, options.inspect)}")
|
|
323
|
+
OPENBOLT_OPTIONS.each { |key, meta| options[key] ||= meta[:default] if meta.key?(:default) }
|
|
324
|
+
logger.info("Options with required defaults: #{scrub(options, options.inspect)}")
|
|
325
|
+
|
|
326
|
+
# Choria transport defaults: fill in config file, SSL certs, and
|
|
327
|
+
# certname when the user has not provided them.
|
|
328
|
+
if options['transport'] == 'choria'
|
|
329
|
+
user_provided_config = options.key?('choria-config-file')
|
|
330
|
+
|
|
331
|
+
unless user_provided_config
|
|
332
|
+
shipped_config = File.join(File.dirname(__FILE__), 'config', 'choria-client.conf')
|
|
333
|
+
if File.readable?(shipped_config)
|
|
334
|
+
options['choria-config-file'] = shipped_config
|
|
335
|
+
else
|
|
336
|
+
logger.warn("Choria: shipped config at #{shipped_config} is not readable " \
|
|
337
|
+
"(exists=#{File.exist?(shipped_config)}). Check package installation " \
|
|
338
|
+
"and foreman-proxy user permissions.")
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
if !user_provided_config
|
|
343
|
+
missing_ssl = []
|
|
344
|
+
missing_ssl << 'ssl_certificate' if Proxy::SETTINGS.ssl_certificate.to_s.strip.empty?
|
|
345
|
+
missing_ssl << 'ssl_private_key' if Proxy::SETTINGS.ssl_private_key.to_s.strip.empty?
|
|
346
|
+
missing_ssl << 'ssl_ca_file' if Proxy::SETTINGS.ssl_ca_file.to_s.strip.empty?
|
|
347
|
+
|
|
348
|
+
if missing_ssl.empty?
|
|
349
|
+
options['choria-ssl-cert'] ||= Proxy::SETTINGS.ssl_certificate
|
|
350
|
+
options['choria-ssl-key'] ||= Proxy::SETTINGS.ssl_private_key
|
|
351
|
+
options['choria-ssl-ca'] ||= Proxy::SETTINGS.ssl_ca_file
|
|
352
|
+
else
|
|
353
|
+
logger.warn("Choria: cannot default SSL from proxy settings, missing: #{missing_ssl.join(', ')}. " \
|
|
354
|
+
"Set choria-ssl-cert, choria-ssl-key, and choria-ssl-ca explicitly.")
|
|
355
|
+
end
|
|
356
|
+
elsif !options.key?('choria-ssl-cert')
|
|
357
|
+
logger.info('Choria: custom config file provided without SSL options. ' \
|
|
358
|
+
'SSL settings will be read from the config file.')
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
unless options.key?('choria-mcollective-certname')
|
|
362
|
+
cert_path = options['choria-ssl-cert']
|
|
363
|
+
if cert_path.nil? && user_provided_config
|
|
364
|
+
logger.info('Choria: custom config file provided, certname will come from the config file or ' \
|
|
365
|
+
"default to '<user>.mcollective'. Set 'choria-mcollective-certname' if needed.")
|
|
366
|
+
elsif cert_path.nil?
|
|
367
|
+
logger.warn('Choria: no choria-ssl-cert available, cannot derive mcollective-certname.')
|
|
368
|
+
elsif !File.readable?(cert_path)
|
|
369
|
+
logger.warn("Choria: cannot derive mcollective-certname, cert at #{cert_path} is not readable. " \
|
|
370
|
+
"Set 'choria-mcollective-certname' explicitly or fix file permissions.")
|
|
371
|
+
else
|
|
372
|
+
begin
|
|
373
|
+
cert = OpenSSL::X509::Certificate.new(File.read(cert_path))
|
|
374
|
+
cn = cert.subject.to_a.find { |name, _, _| name == 'CN' }
|
|
375
|
+
if cn
|
|
376
|
+
options['choria-mcollective-certname'] = cn[1]
|
|
377
|
+
else
|
|
378
|
+
logger.warn("Choria: certificate at #{cert_path} has no CN. " \
|
|
379
|
+
"Set 'choria-mcollective-certname' explicitly.")
|
|
380
|
+
end
|
|
381
|
+
rescue OpenSSL::X509::CertificateError => e
|
|
382
|
+
raise Error.new(
|
|
383
|
+
message: "Cannot read Choria certificate at #{cert_path}: #{e.message}. " \
|
|
384
|
+
"Set 'choria-mcollective-certname' explicitly or fix the certificate file."
|
|
385
|
+
)
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
logger.info("Choria options after defaults: #{scrub(options, options.inspect)}")
|
|
391
|
+
end
|
|
259
392
|
|
|
260
393
|
# Validate option types
|
|
261
|
-
options = options.
|
|
394
|
+
options = options.to_h do |key, value|
|
|
262
395
|
type = OPENBOLT_OPTIONS[key][:type]
|
|
263
|
-
value = value.nil? ? '' : value # Just in case
|
|
264
396
|
case type
|
|
265
397
|
when :boolean
|
|
266
398
|
if value.is_a?(String)
|
|
267
399
|
value = value.downcase.strip
|
|
268
|
-
raise
|
|
400
|
+
raise Error.new(message: "Option #{key} must be a boolean 'true' or 'false'. Current value: #{value}") unless ['true', 'false'].include?(value)
|
|
269
401
|
value = value == 'true'
|
|
270
402
|
end
|
|
271
|
-
raise
|
|
403
|
+
raise Error.new(message: "Option #{key} must be a boolean true or false. It appears to be #{value.class}.") unless [TrueClass, FalseClass].include?(value.class)
|
|
272
404
|
when :string
|
|
273
|
-
value
|
|
274
|
-
raise Proxy::OpenBolt::Error.new(message: "Option #{key} must have a value when the option is specified.") if value.empty?
|
|
405
|
+
raise Error.new(message: "Option #{key} must have a value when the option is specified.") if value.to_s.empty?
|
|
275
406
|
when Array
|
|
276
|
-
|
|
277
|
-
raise Proxy::OpenBolt::Error.new(message: "Option #{key} must have one of the following values: #{OPENBOLT_OPTIONS[key][:type]}") unless OPENBOLT_OPTIONS[key][:type].include?(value)
|
|
407
|
+
raise Error.new(message: "Option #{key} must have one of the following values: #{OPENBOLT_OPTIONS[key][:type]}") unless OPENBOLT_OPTIONS[key][:type].include?(value.to_s)
|
|
278
408
|
end
|
|
279
409
|
[key, value]
|
|
280
|
-
end
|
|
281
|
-
logger.info("Final options: #{scrub(options, options.inspect
|
|
410
|
+
end
|
|
411
|
+
logger.info("Final options: #{scrub(options, options.inspect)}")
|
|
282
412
|
|
|
283
413
|
### Run the task ###
|
|
284
414
|
task = TaskJob.new(name, params, options, targets)
|
|
285
415
|
id = executor.add_job(task)
|
|
286
416
|
|
|
287
|
-
|
|
288
|
-
id: id
|
|
289
|
-
}.to_json
|
|
417
|
+
{ id: id }.to_json
|
|
290
418
|
end
|
|
291
419
|
|
|
292
420
|
# /job/:id/status
|
|
293
421
|
def get_status(id)
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
}.to_json
|
|
422
|
+
validate_job_id!(id)
|
|
423
|
+
{ status: executor.status(id) }.to_json
|
|
297
424
|
end
|
|
298
425
|
|
|
299
426
|
# /job/:id/result
|
|
300
427
|
def get_result(id)
|
|
301
|
-
|
|
428
|
+
validate_job_id!(id)
|
|
429
|
+
result = executor.result(id)
|
|
430
|
+
return result if result.is_a?(String)
|
|
431
|
+
raise Error.new(message: "Job not found: #{id}") if result == :invalid
|
|
432
|
+
result.to_json
|
|
433
|
+
rescue Errno::ENOENT
|
|
434
|
+
raise Error.new(message: "Result file not found for job: #{id}")
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
# DELETE /job/:id/artifacts
|
|
438
|
+
def delete_artifacts(id)
|
|
439
|
+
validate_job_id!(id)
|
|
440
|
+
|
|
441
|
+
file_path = result_file_path(id)
|
|
442
|
+
real_path = File.realpath(file_path)
|
|
443
|
+
expected_dir = File.realpath(Plugin.settings.log_dir)
|
|
444
|
+
raise Error.new(message: 'Invalid file path') unless real_path.start_with?(expected_dir)
|
|
445
|
+
|
|
446
|
+
File.delete(file_path)
|
|
447
|
+
executor.remove_job(id)
|
|
448
|
+
logger.info("Deleted artifacts for job #{id}")
|
|
449
|
+
{ status: 'deleted', job_id: id }.to_json
|
|
450
|
+
rescue Errno::ENOENT
|
|
451
|
+
logger.warning("Artifacts not found for job #{id}")
|
|
452
|
+
{ status: 'not_found', job_id: id }.to_json
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
# Runs an openbolt command that is expected to produce JSON on stdout.
|
|
456
|
+
# Returns the parsed JSON hash. Raises CliError on non-zero exit or
|
|
457
|
+
# Error on JSON parse failure.
|
|
458
|
+
def openbolt_json(command)
|
|
459
|
+
stdout, stderr, exitcode = openbolt(command)
|
|
460
|
+
unless exitcode.zero?
|
|
461
|
+
raise CliError.new(
|
|
462
|
+
message: "Error running '#{command.first(4).join(' ')}'.",
|
|
463
|
+
exitcode: exitcode,
|
|
464
|
+
stdout: stdout,
|
|
465
|
+
stderr: stderr,
|
|
466
|
+
command: command.join(' ')
|
|
467
|
+
)
|
|
468
|
+
end
|
|
469
|
+
begin
|
|
470
|
+
JSON.parse(stdout)
|
|
471
|
+
rescue JSON::ParserError => e
|
|
472
|
+
raise Error.new(
|
|
473
|
+
message: "Error parsing JSON output from '#{command.first(4).join(' ')}'.",
|
|
474
|
+
exception: e
|
|
475
|
+
)
|
|
476
|
+
end
|
|
302
477
|
end
|
|
303
478
|
|
|
304
479
|
# Anything that needs to run an OpenBolt CLI command should use this.
|
|
@@ -309,17 +484,29 @@ module Proxy::OpenBolt
|
|
|
309
484
|
# --format json is specified. At some point, figure out how to make
|
|
310
485
|
# OpenBolt's logger log to a file instead without having to have a special
|
|
311
486
|
# project config file.
|
|
487
|
+
# Returns [stdout, stderr, exitcode]. Handles the case where the
|
|
488
|
+
# process is killed by a signal (exitstatus is nil).
|
|
312
489
|
def openbolt(command)
|
|
313
490
|
env = { 'BOLT_GEM' => 'true', 'BOLT_DISABLE_ANALYTICS' => 'true' }
|
|
314
|
-
Open3.capture3(env, *command
|
|
491
|
+
stdout, stderr, status = Open3.capture3(env, *command)
|
|
492
|
+
exitcode = status.exitstatus
|
|
493
|
+
if exitcode.nil?
|
|
494
|
+
# 128 + signal follows the Unix/shell convention for signal exit codes.
|
|
495
|
+
exitcode = 128 + (status.termsig || 0)
|
|
496
|
+
stderr = "Process was killed by signal #{status.termsig}.\n#{stderr}"
|
|
497
|
+
end
|
|
498
|
+
[stdout, stderr, exitcode]
|
|
315
499
|
end
|
|
316
500
|
|
|
317
|
-
# Probably needs to go in a utils class somewhere
|
|
318
501
|
# Used only for display text that may contain sensitive OpenBolt
|
|
319
|
-
# options values. Should
|
|
502
|
+
# options values. Should not be used to pass anything to the CLI.
|
|
320
503
|
def scrub(options, text)
|
|
321
504
|
sensitive = options.select { |key, _| OPENBOLT_OPTIONS[key] && OPENBOLT_OPTIONS[key][:sensitive] }
|
|
322
|
-
sensitive.
|
|
505
|
+
sensitive.each_value do |value|
|
|
506
|
+
redact = value.to_s
|
|
507
|
+
next if redact.empty?
|
|
508
|
+
text = text.gsub(redact, '*****')
|
|
509
|
+
end
|
|
323
510
|
text
|
|
324
511
|
end
|
|
325
512
|
end
|
|
@@ -1,24 +1,22 @@
|
|
|
1
1
|
require 'fileutils'
|
|
2
2
|
|
|
3
3
|
module Proxy::OpenBolt
|
|
4
|
-
class NotFound < RuntimeError; end
|
|
5
|
-
|
|
6
4
|
class LogPathValidator < ::Proxy::PluginValidators::Base
|
|
7
5
|
def validate!(settings)
|
|
8
6
|
logdir = settings[:log_dir]
|
|
9
7
|
unless Dir.exist?(logdir)
|
|
10
8
|
FileUtils.mkdir_p(logdir)
|
|
11
|
-
|
|
9
|
+
if Process.uid == 0
|
|
10
|
+
FileUtils.chown('foreman-proxy', 'foreman-proxy', logdir)
|
|
11
|
+
end
|
|
12
12
|
FileUtils.chmod(0750, logdir)
|
|
13
13
|
end
|
|
14
|
-
raise ::Proxy::Error::ConfigurationError
|
|
14
|
+
raise ::Proxy::Error::ConfigurationError, "Could not create log dir at #{logdir}" unless Dir.exist?(logdir)
|
|
15
15
|
end
|
|
16
16
|
end
|
|
17
17
|
|
|
18
18
|
class Plugin < ::Proxy::Plugin
|
|
19
|
-
plugin :openbolt,
|
|
20
|
-
|
|
21
|
-
expose_setting :enabled
|
|
19
|
+
plugin :openbolt, VERSION
|
|
22
20
|
|
|
23
21
|
capability :tasks
|
|
24
22
|
|
|
@@ -31,10 +29,10 @@ module Proxy::OpenBolt
|
|
|
31
29
|
log_dir: '/var/log/foreman-proxy/openbolt'
|
|
32
30
|
)
|
|
33
31
|
|
|
34
|
-
load_validators :log_path_validator =>
|
|
32
|
+
load_validators :log_path_validator => LogPathValidator
|
|
35
33
|
validate_readable :environment_path
|
|
36
34
|
validate :log_dir, :log_path_validator => true
|
|
37
35
|
|
|
38
|
-
https_rackup_path File.expand_path('http_config.ru', File.expand_path(
|
|
36
|
+
https_rackup_path File.expand_path('http_config.ru', File.expand_path(__dir__))
|
|
39
37
|
end
|
|
40
38
|
end
|
|
@@ -2,7 +2,6 @@ require 'json'
|
|
|
2
2
|
|
|
3
3
|
module Proxy::OpenBolt
|
|
4
4
|
class Result
|
|
5
|
-
|
|
6
5
|
attr_reader :command, :status, :value, :log, :message, :schema
|
|
7
6
|
|
|
8
7
|
# Result from the OpenBolt CLI with --format json looks like:
|
|
@@ -38,35 +37,33 @@ module Proxy::OpenBolt
|
|
|
38
37
|
@message = "Command unexpectedly exited with code #{exitcode}"
|
|
39
38
|
@status = :exception
|
|
40
39
|
@value = "stderr:\n#{stderr}\nstdout:\n#{stdout}"
|
|
40
|
+
elsif exitcode == 1 && !stdout.start_with?('{')
|
|
41
|
+
@value = stdout
|
|
42
|
+
@status = :failure
|
|
43
|
+
@log = stderr
|
|
41
44
|
else
|
|
42
|
-
|
|
43
|
-
@value = stdout
|
|
44
|
-
@status = :failure
|
|
45
|
+
begin
|
|
46
|
+
@value = JSON.parse(stdout)
|
|
47
|
+
@status = exitcode == 0 ? :success : :failure
|
|
48
|
+
@log = stderr
|
|
49
|
+
rescue JSON::ParserError => e
|
|
50
|
+
@status = :exception
|
|
51
|
+
@message = e.message
|
|
52
|
+
@value = e.inspect
|
|
45
53
|
@log = stderr
|
|
46
|
-
else
|
|
47
|
-
begin
|
|
48
|
-
@value = JSON.parse(stdout)
|
|
49
|
-
@status = exitcode == 0 ? :success : :failure
|
|
50
|
-
@log = stderr
|
|
51
|
-
rescue JSON::ParserError => e
|
|
52
|
-
@status = :exception
|
|
53
|
-
@message = e.message
|
|
54
|
-
@value = e.inspect
|
|
55
|
-
@log = stderr
|
|
56
|
-
end
|
|
57
54
|
end
|
|
58
55
|
end
|
|
59
56
|
end
|
|
60
57
|
|
|
61
|
-
def to_json
|
|
58
|
+
def to_json(*args)
|
|
62
59
|
{
|
|
63
|
-
'command'
|
|
64
|
-
'status'
|
|
65
|
-
'value'
|
|
66
|
-
'log'
|
|
67
|
-
'message'
|
|
68
|
-
'schema'
|
|
69
|
-
}.to_json
|
|
60
|
+
'command' => @command,
|
|
61
|
+
'status' => @status,
|
|
62
|
+
'value' => @value,
|
|
63
|
+
'log' => @log,
|
|
64
|
+
'message' => @message,
|
|
65
|
+
'schema' => @schema,
|
|
66
|
+
}.to_json(*args)
|
|
70
67
|
end
|
|
71
68
|
end
|
|
72
69
|
end
|