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.
@@ -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
- class << self
101
- @@mutex = Mutex.new
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
- OPENBOLT_OPTIONS.sort.to_h
184
+ SORTED_OPTIONS
105
185
  end
106
186
 
107
187
  def executor
108
- @executor ||= Proxy::OpenBolt::Executor.instance
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
- @@mutex.synchronize do
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 = "bolt task show --project #{Proxy::OpenBolt::Plugin.settings.environment_path} --format json"
127
- stdout, stderr, status = openbolt(command)
128
- unless status.exitstatus.zero?
129
- raise Proxy::OpenBolt::CliError.new(
130
- message: 'Error occurred when fetching tasks names.',
131
- exitcode: status.exitstatus,
132
- stdout: stdout,
133
- stderr: stderr,
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 and put into @tasks
148
- task_names.each do |name|
149
- command = "bolt task show #{name} --project #{Proxy::OpenBolt::Plugin.settings.environment_path} --format json"
150
- stdout, stderr, status = openbolt(command)
151
- unless status.exitstatus.zero?
152
- @tasks = nil
153
- raise Proxy::OpenBolt::CliError.new(
154
- message: "Error occurred when fetching task information for #{name}",
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
- @tasks = nil
173
- raise Proxy::OpenBolt::Error.new(
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 Proxy::OpenBolt::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.').to_json
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.keys.include?(k) }
210
- raise Proxy::OpenBolt::Error.new(message: "You must provide values for 'name', 'parameters', 'targets', and 'transport'.")
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.to_s)}")
281
+ logger.info("Options: #{scrub(options, options.inspect)}")
221
282
 
222
283
  # Validate name
223
- raise Proxy::OpenBolt::Error.new(message: "You must provide a value for 'name'.") unless name.is_a?(String) && !name.empty?
224
- raise Proxy::OpenBolt::Error.new(message: "Task #{name} not found.") unless tasks.keys.include?(name)
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 Proxy::OpenBolt::Error.new(message: "The 'parameters' value should be a hash.") unless params.is_a?(Hash)
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 Proxy::OpenBolt::Error.new(message: "Unknown parameters: #{extra}") unless extra.empty?
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 Proxy::OpenBolt::Error.new(message: "The 'targets' value should be a string or an array.'") unless targets.is_a?(String) || targets.is_a?(Array)
243
- targets = targets.split(',').map { |t| t.strip }
244
- raise Proxy::OpenBolt::Error.new(message: "The 'targets' value should not be empty.") if targets.empty?
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 Proxy::OpenBolt::Error.new(message: "The 'options' value should be a hash.") unless options.is_a?(Hash)
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 Proxy::OpenBolt::Error.new(message: "Invalid options specified: #{unknown}") unless unknown.empty?
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.to_s)}")
257
- OPENBOLT_OPTIONS.each { |key, value| options[key] ||= value[:default] if value.key?(:default) }
258
- logger.info("Options with required defaults: #{scrub(options, options.inspect.to_s)}")
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.map do |key, value|
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 Proxy::OpenBolt::Error.new(message: "Option #{key} must be a boolean 'true' or 'false'. Current value: #{value}") unless ['true', 'false'].include?(value)
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 Proxy::OpenBolt::Error.new(message: "Option #{key} must be a boolean true for false. It appears to be #{value.class}.") unless [TrueClass, FalseClass].include?(value.class)
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 = value.strip
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
- value = value.strip
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.to_h
281
- logger.info("Final options: #{scrub(options, options.inspect.to_s)}")
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
- return {
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
- return {
295
- status: executor.status(id),
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
- executor.result(id).to_json
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.split)
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 to be used to pass anything to the CLI.
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.each { |_, value| text = text.gsub(value, '*****') }
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
- FileUtils.chown('foreman-proxy','foreman-proxy',logdir)
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("Could not create log dir at #{logdir}") unless Dir.exist?(logdir)
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, Proxy::OpenBolt::VERSION
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 => Proxy::OpenBolt::LogPathValidator
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('../', __FILE__))
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
- if exitcode == 1 && !stdout.start_with?('{')
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': @command,
64
- 'status': @status,
65
- 'value': @value,
66
- 'log': @log,
67
- 'message': @message,
68
- 'schema': @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