# frozen_string_literal: true require 'json' require 'bolt/error' module Bolt class Result attr_reader :target, :value, :action, :object def self.from_exception(target, exception, action: 'action', position: []) details = create_details(position) if exception.is_a?(Bolt::Error) error = Bolt::Util.deep_merge({ 'details' => details }, exception.to_h) else details['class'] = exception.class.to_s error = { 'kind' => 'puppetlabs.tasks/exception-error', 'issue_code' => 'EXCEPTION', 'msg' => exception.message, 'details' => details } error['details']['stack_trace'] = exception.backtrace.join('\n') if exception.backtrace end Result.new(target, error: error, action: action) end def self.create_details(position) %w[file line].zip(position).to_h.compact end def self.for_command(target, value, action, command, position) details = create_details(position) unless value['exit_code'] == 0 details['exit_code'] = value['exit_code'] value['_error'] = { 'kind' => 'puppetlabs.tasks/command-error', 'issue_code' => 'COMMAND_ERROR', 'msg' => "The command failed with exit code #{value['exit_code']}", 'details' => details } end new(target, value: value, action: action, object: command) end def self.for_task(target, stdout, stderr, exit_code, task, position) stdout.force_encoding('utf-8') unless stdout.encoding == Encoding::UTF_8 details = create_details(position) value = if stdout.valid_encoding? parse_hash(stdout) || { '_output' => stdout } else { '_error' => { 'kind' => 'puppetlabs.tasks/task-error', 'issue_code' => 'TASK_ERROR', 'msg' => 'The task result contained invalid UTF-8 on stdout', 'details' => details } } end if exit_code != 0 && value['_error'].nil? msg = if stdout.empty? if stderr.empty? "The task failed with exit code #{exit_code} and no output" else "The task failed with exit code #{exit_code} and no stdout, but stderr contained:\n#{stderr}" end else "The task failed with exit code #{exit_code}" end details['exit_code'] = exit_code value['_error'] = { 'kind' => 'puppetlabs.tasks/task-error', 'issue_code' => 'TASK_ERROR', 'msg' => msg, 'details' => details } end if value.key?('_error') unless value['_error'].is_a?(Hash) && value['_error'].key?('msg') details['original_error'] = value['_error'] value['_error'] = { 'msg' => "Invalid error returned from task #{task}: #{value['_error'].inspect}. Error "\ "must be an object with a msg key.", 'kind' => 'bolt/invalid-task-error', 'details' => details } end value['_error']['kind'] ||= 'bolt/error' value['_error']['details'] ||= details end if value.key?('_sensitive') value['_sensitive'] = Puppet::Pops::Types::PSensitiveType::Sensitive.new(value['_sensitive']) end new(target, value: value, action: 'task', object: task) end def self.parse_hash(string) value = JSON.parse(string) value if value.is_a? Hash rescue JSON::ParserError nil end def self.for_upload(target, source, destination) new(target, message: "Uploaded '#{source}' to '#{target.host}:#{destination}'", action: 'upload', object: source) end def self.for_download(target, source, destination, download) msg = "Downloaded '#{target.host}:#{source}' to '#{destination}'" value = { 'path' => download } new(target, value: value, message: msg, action: 'download', object: source) end # Satisfies the Puppet datatypes API def self.from_asserted_args(target, value) new(target, value: value) end def self._pcore_init_from_hash raise "Result shouldn't be instantiated from a pcore_init class method. How did this get called?" end def _pcore_init_from_hash(init_hash) opts = init_hash.reject { |k, _v| k == 'target' } initialize(init_hash['target'], opts.transform_keys(&:to_sym)) end def _pcore_init_hash { 'target' => @target, 'error' => @value['_error'], 'message' => @value['_output'], 'value' => @value, 'action' => @action, 'object' => @object } end def initialize(target, error: nil, message: nil, value: nil, action: 'action', object: nil) @target = target @value = value || {} @action = action @object = object if error && !error.is_a?(Hash) raise "TODO: how did we get a string error" end @value['_error'] = error if error @value['_output'] = message if message end def message @value['_output'] end def message? message && !message.strip.empty? end def generic_value safe_value.reject { |k, _| %w[_error _output].include? k } end def eql?(other) self.class == other.class && target == other.target && value == other.value end def [](key) value[key] end def ==(other) eql?(other) end def to_json(opts = nil) to_data.to_json(opts) end def to_s to_json end # This is the value with all non-UTF-8 characters removed, suitable for # printing or converting to JSON. It *should* only be possible to have # non-UTF-8 characters in stdout/stderr keys as they are not allowed from # tasks but we scrub the whole thing just in case. def safe_value Bolt::Util.walk_vals(value) do |val| if val.is_a?(String) # Replace invalid bytes with hex codes, ie. \xDE\xAD\xBE\xEF val.scrub { |c| c.bytes.map { |b| "\\x" + b.to_s(16).upcase }.join } else val end end end def to_data serialized_value = safe_value if serialized_value.key?('_sensitive') && serialized_value['_sensitive'].is_a?(Puppet::Pops::Types::PSensitiveType::Sensitive) serialized_value['_sensitive'] = serialized_value['_sensitive'].to_s end { "target" => @target.name, "action" => action, "object" => object, "status" => status, "value" => serialized_value } end def status ok? ? 'success' : 'failure' end def ok? error_hash.nil? end alias ok ok? alias success? ok? # This allows access to errors outside puppet compilation # it should be prefered over error in bolt code def error_hash value['_error'] end # Warning: This will fail outside of a compilation. # Use error_hash inside bolt. # Is it crazy for this to behave differently outside a compiler? def error if error_hash Puppet::DataTypes::Error.from_asserted_hash(error_hash) end end def sensitive value['_sensitive'] end end end