Module: Proxy::OpenBolt

Extended by:
Log, Util
Defined in:
lib/smart_proxy_openbolt/version.rb,
lib/smart_proxy_openbolt/executor.rb,
lib/smart_proxy_openbolt/task_job.rb,
lib/smart_proxy_openbolt/lru_cache.rb,
lib/smart_proxy_openbolt/result.rb,
lib/smart_proxy_openbolt/plugin.rb,
lib/smart_proxy_openbolt/error.rb,
lib/smart_proxy_openbolt/main.rb,
lib/smart_proxy_openbolt/job.rb,
lib/smart_proxy_openbolt/api.rb

Defined Under Namespace

Classes: Api, CliError, Error, Executor, Job, LogPathValidator, LruCache, Plugin, Result, TaskJob

Constant Summary collapse

VERSION =
'1.2.0'.freeze
TRANSPORTS =
['ssh', 'winrm', 'choria'].freeze
OPENBOLT_OPTIONS =

The key should be exactly the flag name passed to OpenBolt Type must be :boolean, :string, or an array of acceptable string values Transport must be an array of transport types it applies to. This is

used to filter the openbolt options in the UI to only those relevant

Defaults set here are in case the UI does not send any information for

the key, and should only be present if this value is required

Sensitive should be set to true in order to redact the value from logs

{
  'transport' => {
    :type => TRANSPORTS,
    :transport => TRANSPORTS,
    :default => 'ssh',
    :sensitive => false,
    :description => 'The transport method to use for connecting to target hosts.',
  },
  'log-level' => {
    :type => ['error', 'warning', 'info', 'debug', 'trace'],
    :transport => ['ssh', 'winrm', 'choria'],
    :sensitive => false,
    :description => 'Set the log level during OpenBolt execution.',
  },
  'verbose' => {
    :type => :boolean,
    :transport => ['ssh', 'winrm', 'choria'],
    :sensitive => false,
    :description => 'Run the OpenBolt command with the --verbose flag. This prints additional information during OpenBolt execution and will print any out::verbose plan statements.',
  },
  'noop' => {
    :type => :boolean,
    :transport => ['ssh', 'winrm', 'choria'],
    :sensitive => false,
    :description => 'Run the OpenBolt command with the --noop flag, which will make no changes to the target host.',
  },
  'tmpdir' => {
    :type => :string,
    :transport => ['ssh', 'winrm', 'choria'],
    :sensitive => false,
    :description => 'Directory to use for temporary files on target hosts during OpenBolt execution.',
  },
  'user' => {
    :type => :string,
    :transport => ['ssh', 'winrm'],
    :sensitive => false,
    :description => 'Username used for SSH or WinRM authentication.',
  },
  'password' => {
    :type => :string,
    :transport => ['ssh', 'winrm'],
    :sensitive => true,
    :description => 'Password used for SSH or WinRM authentication.',
  },
  'host-key-check' => {
    :type => :boolean,
    :transport => ['ssh'],
    :sensitive => false,
    :description => 'When enabled, perform host key verification when connecting to targets over SSH.',
  },
  'private-key' => {
    :type => :string,
    :transport => ['ssh'],
    :sensitive => false,
    :description => 'Path on the smart proxy host to the private key used for SSH authentication. This key must be readable by the foreman-proxy user.',
  },
  'run-as' => {
    :type => :string,
    :transport => ['ssh'],
    :sensitive => false,
    :description => 'The user to run commands as on the target host. This requires that the user specified in the "user" option has permission to run commands as this user.',
  },
  'sudo-password' => {
    :type => :string,
    :transport => ['ssh'],
    :sensitive => true,
    :description => 'Password used for privilege escalation when using SSH.',
  },
  'ssl' => {
    :type => :boolean,
    :transport => ['winrm'],
    :sensitive => false,
    :description => 'Use SSL when connecting to hosts via WinRM.',
  },
  'ssl-verify' => {
    :type => :boolean,
    :transport => ['winrm'],
    :sensitive => false,
    :description => 'Verify remote host SSL certificate when connecting to hosts via WinRM.',
  },
  'choria-task-agent' => {
    :type => ['bolt_tasks', 'shell'],
    :transport => ['choria'],
    :sensitive => false,
    :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.',
  },
  'choria-config-file' => {
    :type => :string,
    :transport => ['choria'],
    :sensitive => false,
    :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.',
  },
  'choria-mcollective-certname' => {
    :type => :string,
    :transport => ['choria'],
    :sensitive => false,
    :description => 'Override the MCollective certname for Choria client identity. When blank, the proxy derives this automatically from the SSL certificate.',
  },
  'choria-ssl-ca' => {
    :type => :string,
    :transport => ['choria'],
    :sensitive => false,
    :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.',
  },
  'choria-ssl-cert' => {
    :type => :string,
    :transport => ['choria'],
    :sensitive => false,
    :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.',
  },
  'choria-ssl-key' => {
    :type => :string,
    :transport => ['choria'],
    :sensitive => false,
    :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.',
  },
  'choria-collective' => {
    :type => :string,
    :transport => ['choria'],
    :sensitive => false,
    :description => 'Choria collective to route messages through.',
  },
  'choria-puppet-environment' => {
    :type => :string,
    :transport => ['choria'],
    :sensitive => false,
    :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.',
  },
  'choria-rpc-timeout' => {
    :type => :string,
    :transport => ['choria'],
    :sensitive => false,
    :description => 'Timeout in seconds for individual Choria RPC calls. Defaults to 30 when not specified.',
  },
  'choria-task-timeout' => {
    :type => :string,
    :transport => ['choria'],
    :sensitive => false,
    :description => 'Timeout in seconds for a Choria task to complete on a target node. Defaults to 300 when not specified.',
  },
  'choria-command-timeout' => {
    :type => :string,
    :transport => ['choria'],
    :sensitive => false,
    :description => 'Timeout in seconds for a Choria shell command to complete on a target node. Defaults to 60 when not specified.',
  },
  'choria-brokers' => {
    :type => :string,
    :transport => ['choria'],
    :sensitive => false,
    :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.',
  },
  'choria-broker-timeout' => {
    :type => :string,
    :transport => ['choria'],
    :sensitive => false,
    :description => 'Timeout in seconds for establishing a connection to a Choria broker.',
  },
}.freeze
SORTED_OPTIONS =
OPENBOLT_OPTIONS.sort.to_h.freeze

Class Method Summary collapse

Class Method Details

.delete_artifacts(id) ⇒ Object

DELETE /job/:id/artifacts



438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
# File 'lib/smart_proxy_openbolt/main.rb', line 438

def delete_artifacts(id)
  validate_job_id!(id)

  file_path = result_file_path(id)
  real_path = File.realpath(file_path)
  expected_dir = File.realpath(Plugin.settings.log_dir)
  raise Error.new(message: 'Invalid file path') unless real_path.start_with?(expected_dir)

  File.delete(file_path)
  executor.remove_job(id)
  logger.info("Deleted artifacts for job #{id}")
  { status: 'deleted', job_id: id }.to_json
rescue Errno::ENOENT
  logger.warning("Artifacts not found for job #{id}")
  { status: 'not_found', job_id: id }.to_json
end

.executorObject



187
188
189
# File 'lib/smart_proxy_openbolt/main.rb', line 187

def executor
  @executor ||= Executor.instance
end

.get_result(id) ⇒ Object

/job/:id/result



427
428
429
430
431
432
433
434
435
# File 'lib/smart_proxy_openbolt/main.rb', line 427

def get_result(id)
  validate_job_id!(id)
  result = executor.result(id)
  return result if result.is_a?(String)
  raise Error.new(message: "Job not found: #{id}") if result == :invalid
  result.to_json
rescue Errno::ENOENT
  raise Error.new(message: "Result file not found for job: #{id}")
end

.get_status(id) ⇒ Object

/job/:id/status



421
422
423
424
# File 'lib/smart_proxy_openbolt/main.rb', line 421

def get_status(id)
  validate_job_id!(id)
  { status: executor.status(id) }.to_json
end

.launch_task(data) ⇒ Object

/launch/task

Raises:



264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
# File 'lib/smart_proxy_openbolt/main.rb', line 264

def launch_task(data)
  ### Validation ###
  unless data.is_a?(Hash)
    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.')
  end
  fields = ['name', 'parameters', 'targets', 'options']
  unless fields.all? { |k| data.key?(k) }
    raise Error.new(message: "You must provide values for 'name', 'parameters', 'targets', and 'options'.")
  end
  name = data['name']
  params = data['parameters'] || {}
  targets = data['targets']
  options = data['options'] || {}

  logger.info("Task: #{name}")
  logger.info("Parameters: #{params.inspect}")
  logger.info("Targets: #{targets.inspect}")
  logger.info("Options: #{scrub(options, options.inspect)}")

  # Validate name
  raise Error.new(message: "You must provide a value for 'name'.") unless name.is_a?(String) && !name.empty?
  raise Error.new(message: "Task #{name} not found.") unless tasks.key?(name)

  # Validate parameters
  raise Error.new(message: "The 'parameters' value should be a hash.") unless params.is_a?(Hash)
  extra = params.keys - tasks[name]['parameters'].keys
  raise Error.new(message: "Unknown parameters: #{extra}") unless extra.empty?

  # Normalize parameters, ensuring blank values are not passed
  params = normalize_values(params)
  logger.info("Normalized parameters: #{params.inspect}")

  # Check required parameters after normalization so blank values are caught
  missing = []
  tasks[name]['parameters'].each do |k, v|
    next if v['type']&.start_with?('Optional[')
    next if v.key?('default')
    missing << k unless params.key?(k)
  end
  raise Error.new(message: "Missing required parameters: #{missing}") unless missing.empty?

  # Validate targets
  raise Error.new(message: "The 'targets' value should be a string or an array.") unless targets.is_a?(String) || targets.is_a?(Array)
  if targets.is_a?(Array)
    raise Error.new(message: "All target values must be strings.") unless targets.all?(String)
    targets = targets.map(&:strip).reject(&:empty?)
  else
    targets = targets.split(',').map(&:strip).reject(&:empty?)
  end
  raise Error.new(message: "The 'targets' value should not be empty.") if targets.empty?

  # Validate options
  raise Error.new(message: "The 'options' value should be a hash.") unless options.is_a?(Hash)
  unknown = options.keys - OPENBOLT_OPTIONS.keys
  raise Error.new(message: "Invalid options specified: #{unknown}") unless unknown.empty?

  # Normalize options, removing blank values
  options = normalize_values(options)
  logger.info("Normalized options: #{scrub(options, options.inspect)}")
  OPENBOLT_OPTIONS.each { |key, meta| options[key] ||= meta[:default] if meta.key?(:default) }
  logger.info("Options with required defaults: #{scrub(options, options.inspect)}")

  # Choria transport defaults: fill in config file, SSL certs, and
  # certname when the user has not provided them.
  if options['transport'] == 'choria'
    user_provided_config = options.key?('choria-config-file')

    unless user_provided_config
      shipped_config = File.join(File.dirname(__FILE__), 'config', 'choria-client.conf')
      if File.readable?(shipped_config)
        options['choria-config-file'] = shipped_config
      else
        logger.warn("Choria: shipped config at #{shipped_config} is not readable " \
                    "(exists=#{File.exist?(shipped_config)}). Check package installation " \
                    "and foreman-proxy user permissions.")
      end
    end

    if !user_provided_config
      missing_ssl = []
      missing_ssl << 'ssl_certificate' if Proxy::SETTINGS.ssl_certificate.to_s.strip.empty?
      missing_ssl << 'ssl_private_key' if Proxy::SETTINGS.ssl_private_key.to_s.strip.empty?
      missing_ssl << 'ssl_ca_file' if Proxy::SETTINGS.ssl_ca_file.to_s.strip.empty?

      if missing_ssl.empty?
        options['choria-ssl-cert'] ||= Proxy::SETTINGS.ssl_certificate
        options['choria-ssl-key']  ||= Proxy::SETTINGS.ssl_private_key
        options['choria-ssl-ca']   ||= Proxy::SETTINGS.ssl_ca_file
      else
        logger.warn("Choria: cannot default SSL from proxy settings, missing: #{missing_ssl.join(', ')}. " \
                    "Set choria-ssl-cert, choria-ssl-key, and choria-ssl-ca explicitly.")
      end
    elsif !options.key?('choria-ssl-cert')
      logger.info('Choria: custom config file provided without SSL options. ' \
                  'SSL settings will be read from the config file.')
    end

    unless options.key?('choria-mcollective-certname')
      cert_path = options['choria-ssl-cert']
      if cert_path.nil? && user_provided_config
        logger.info('Choria: custom config file provided, certname will come from the config file or ' \
                    "default to '<user>.mcollective'. Set 'choria-mcollective-certname' if needed.")
      elsif cert_path.nil?
        logger.warn('Choria: no choria-ssl-cert available, cannot derive mcollective-certname.')
      elsif !File.readable?(cert_path)
        logger.warn("Choria: cannot derive mcollective-certname, cert at #{cert_path} is not readable. " \
                    "Set 'choria-mcollective-certname' explicitly or fix file permissions.")
      else
        begin
          cert = OpenSSL::X509::Certificate.new(File.read(cert_path))
          cn = cert.subject.to_a.find { |name, _, _| name == 'CN' }
          if cn
            options['choria-mcollective-certname'] = cn[1]
          else
            logger.warn("Choria: certificate at #{cert_path} has no CN. " \
                        "Set 'choria-mcollective-certname' explicitly.")
          end
        rescue OpenSSL::X509::CertificateError => e
          raise Error.new(
            message: "Cannot read Choria certificate at #{cert_path}: #{e.message}. " \
                     "Set 'choria-mcollective-certname' explicitly or fix the certificate file."
          )
        end
      end
    end

    logger.info("Choria options after defaults: #{scrub(options, options.inspect)}")
  end

  # Validate option types
  options = options.to_h do |key, value|
    type = OPENBOLT_OPTIONS[key][:type]
    case type
    when :boolean
      if value.is_a?(String)
        value = value.downcase.strip
        raise Error.new(message: "Option #{key} must be a boolean 'true' or 'false'. Current value: #{value}") unless ['true', 'false'].include?(value)
        value = value == 'true'
      end
      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)
    when :string
      raise Error.new(message: "Option #{key} must have a value when the option is specified.") if value.to_s.empty?
    when Array
      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)
    end
    [key, value]
  end
  logger.info("Final options: #{scrub(options, options.inspect)}")

  ### Run the task ###
  task = TaskJob.new(name, params, options, targets)
  id = executor.add_job(task)

  { id: id }.to_json
end

.normalize_values(hash) ⇒ Object

Normalize options and parameters, since the UI may send unspecified options as empty strings



249
250
251
252
253
254
255
256
257
258
259
260
261
# File 'lib/smart_proxy_openbolt/main.rb', line 249

def normalize_values(hash)
  return {} unless hash.is_a?(Hash)
  hash.transform_values do |value|
    if value.is_a?(String)
      value = value.strip
      value = nil if value.empty?
    elsif value.is_a?(Array)
      value = value.map { |v| v.is_a?(String) ? v.strip : v }
      value = nil if value.empty?
    end
    value
  end.compact
end

.openbolt(command) ⇒ Object

Anything that needs to run an OpenBolt CLI command should use this. At the moment, the full output is held in memory and passed back. If this becomes a problem, we can stream to disk and point to it.

For task runs, the log goes to stderr and the result to stdout when –format json is specified. At some point, figure out how to make OpenBolt’s logger log to a file instead without having to have a special project config file. Returns [stdout, stderr, exitcode]. Handles the case where the process is killed by a signal (exitstatus is nil).



489
490
491
492
493
494
495
496
497
498
499
# File 'lib/smart_proxy_openbolt/main.rb', line 489

def openbolt(command)
  env = { 'BOLT_GEM' => 'true', 'BOLT_DISABLE_ANALYTICS' => 'true' }
  stdout, stderr, status = Open3.capture3(env, *command)
  exitcode = status.exitstatus
  if exitcode.nil?
    # 128 + signal follows the Unix/shell convention for signal exit codes.
    exitcode = 128 + (status.termsig || 0)
    stderr = "Process was killed by signal #{status.termsig}.\n#{stderr}"
  end
  [stdout, stderr, exitcode]
end

.openbolt_json(command) ⇒ Object

Runs an openbolt command that is expected to produce JSON on stdout. Returns the parsed JSON hash. Raises CliError on non-zero exit or Error on JSON parse failure.



458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
# File 'lib/smart_proxy_openbolt/main.rb', line 458

def openbolt_json(command)
  stdout, stderr, exitcode = openbolt(command)
  unless exitcode.zero?
    raise CliError.new(
      message:  "Error running '#{command.first(4).join(' ')}'.",
      exitcode: exitcode,
      stdout:   stdout,
      stderr:   stderr,
      command:  command.join(' ')
    )
  end
  begin
    JSON.parse(stdout)
  rescue JSON::ParserError => e
    raise Error.new(
      message:   "Error parsing JSON output from '#{command.first(4).join(' ')}'.",
      exception: e
    )
  end
end

.openbolt_optionsObject



183
184
185
# File 'lib/smart_proxy_openbolt/main.rb', line 183

def openbolt_options
  SORTED_OPTIONS
end

.reload_tasksObject



211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
# File 'lib/smart_proxy_openbolt/main.rb', line 211

def reload_tasks
  task_data = {}

  # Get a list of all tasks
  command = ['bolt', 'task', 'show',
             '--project', Plugin.settings.environment_path,
             '--format', 'json']
  parsed = openbolt_json(command)
  task_list = parsed['tasks']
  unless task_list.is_a?(Array)
    raise Error.new(
      message: "Unexpected output from 'bolt task show': expected 'tasks' to be an array, got #{task_list.class}."
    )
  end

  # Get metadata for each task
  task_list.each do |task_entry|
    name = task_entry[0]
    command = ['bolt', 'task', 'show', name,
               '--project', Plugin.settings.environment_path,
               '--format', 'json']
    result = openbolt_json(command)
     = result['metadata']
    if .nil?
      raise Error.new(
        message: "Invalid metadata found for task #{name}"
      )
    end

    task_data[name] = {
      'description' => ['description'] || '',
      'parameters'  => ['parameters'] || {},
    }
  end
  @tasks = task_data
end

.result_file_path(id) ⇒ Object



196
197
198
# File 'lib/smart_proxy_openbolt/main.rb', line 196

def result_file_path(id)
  File.join(Plugin.settings.log_dir, "#{id}.json")
end

.scrub(options, text) ⇒ Object

Used only for display text that may contain sensitive OpenBolt options values. Should not be used to pass anything to the CLI.



503
504
505
506
507
508
509
510
511
# File 'lib/smart_proxy_openbolt/main.rb', line 503

def scrub(options, text)
  sensitive = options.select { |key, _| OPENBOLT_OPTIONS[key] && OPENBOLT_OPTIONS[key][:sensitive] }
  sensitive.each_value do |value|
    redact = value.to_s
    next if redact.empty?
    text = text.gsub(redact, '*****')
  end
  text
end

.tasks(reload: false) ⇒ Object

/tasks or /tasks/reload



201
202
203
204
205
206
207
208
209
# File 'lib/smart_proxy_openbolt/main.rb', line 201

def tasks(reload: false)
  # If we need to reload, only one instance of the reload
  # should happen at once. Make others wait until it is
  # finished.
  @mutex.synchronize do
    @tasks = nil if reload
    @tasks || reload_tasks
  end
end

.validate_job_id!(id) ⇒ Object

Raises:



191
192
193
194
# File 'lib/smart_proxy_openbolt/main.rb', line 191

def validate_job_id!(id)
  return if /\A[a-f0-9-]+\z/i.match?(id)
  raise Error.new(message: 'Invalid job ID format')
end