module Proxy::OpenBolt

Constants

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

SORTED_OPTIONS
TRANSPORTS
VERSION

Public Class Methods

delete_artifacts(id) click to toggle source

DELETE /job/:id/artifacts

# 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
executor() click to toggle source
# File lib/smart_proxy_openbolt/main.rb, line 187
def executor
  @executor ||= Executor.instance
end
get_result(id) click to toggle source

/job/:id/result

# 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) click to toggle source

/job/:id/status

# 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) click to toggle source

/launch/task

# 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) click to toggle source

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

# 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) click to toggle source

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).

# 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) click to toggle source

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.

# 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_options() click to toggle source
# File lib/smart_proxy_openbolt/main.rb, line 183
def openbolt_options
  SORTED_OPTIONS
end
reload_tasks() click to toggle source
# 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)
    metadata = result['metadata']
    if metadata.nil?
      raise Error.new(
        message: "Invalid metadata found for task #{name}"
      )
    end

    task_data[name] = {
      'description' => metadata['description'] || '',
      'parameters'  => metadata['parameters'] || {},
    }
  end
  @tasks = task_data
end
result_file_path(id) click to toggle source
# 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) click to toggle source

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

# 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) click to toggle source

/tasks or /tasks/reload

# 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) click to toggle source
# 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