module Proxy::OpenBolt
Constants
- OPENBOLT_OPTIONS
The key should be exactly the flag name passed to
OpenBoltType must be :boolean, :string, or an array of acceptable string values Transport must be an array of transport types it applies to. This isused 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 /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
# File lib/smart_proxy_openbolt/main.rb, line 187 def executor @executor ||= Executor.instance end
/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
/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
# 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 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
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
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
# File lib/smart_proxy_openbolt/main.rb, line 183 def openbolt_options SORTED_OPTIONS end
# 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
# File lib/smart_proxy_openbolt/main.rb, line 196 def result_file_path(id) File.join(Plugin.settings.log_dir, "#{id}.json") end
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 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
# 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