#! /usr/bin/env ruby # # check-cucumber # # DESCRIPTION: # A check that executes Cucumber tests # # OUTPUT: # plain text # # PLATFORMS: # Linux # # DEPENDENCIES: # gem: sensu-plugin # gem: english # # USAGE: # #YELLOW # # NOTES: # # LICENSE: # Copyright 2014 Simon Dean # Released under the same terms as Sensu (the MIT license); see LICENSE # for details. # require 'sensu-plugin/check/cli' require 'optparse' require 'timeout' require 'json' require 'yaml' require 'socket' require 'English' # # Check Cucumber # class CheckCucumber < Sensu::Plugin::Check::CLI OK = 0 WARNING = 1 CRITICAL = 2 UNKNOWN = 3 INFINITE_TIMEOUT = 0 option :name, description: 'Name to use in sensu events', short: '-n NAME', long: '--name NAME' option :handler, description: 'Handler to use for sensu events', short: '-h HANDLER', long: '--handler HANDLER' option :metric_handler, description: 'Handler to use for metric events', short: '-m HANDLER', long: '--metric-handler HANDLER' option :metric_prefix, description: 'Metric prefix to use with metric paths in sensu events', short: '-p METRIC_PREFIX', long: '--metric-prefix METRIC_PREFIX' option :command, description: 'Cucumber command line, including arguments', short: '-c COMMAND', long: '--command COMMAND' option :working_dir, description: 'Working directory to use with Cucumber', short: '-w WORKING_DIR', long: '--working-dir WORKING_DIR' option :timeout, description: 'Amount of seconds to wait for Cucumber to wait before killing the Cucumber process', short: '-t TIMEOUT', long: '--timeout TIMEOUT' option :env, description: 'Environment variable to pass to Cucumber. Can be specified more than once to set multiple environment variables', short: '-n NAME=VALUE', long: '--env NAME=VALUE' option :event_data, description: 'Used to add custom data to the sensu events that are raised. Can be specified more than once to set multiple data items', short: '-d NAME=VALUE', long: '--event-data NAME=VALUE' option :attachments, description: 'Specifies whether Cucumber attachments should be included in sensu events. ' \ 'Cucumber attachments can be multi-megabyte if they include screenshots', short: '-a BOOLEAN', long: '--attachments BOOLEAN' option :debug, description: 'Print debug information', long: '--debug', boolean: true def parse_options(argv) env = {} event_data = {} process_env_option = lambda do |config_value| name, value = config_value.split('=', 2) env[name] = value env end process_event_data_option = lambda do |config_value| name, value = config_value.split('=', 2) event_data[name] = value event_data end options[:env][:proc] = process_env_option options[:event_data][:proc] = process_event_data_option super(argv) end def run return unless config_is_valid? result = nil begin result = execute_cucumber(config[:env], config[:command], config[:working_dir], config[:timeout]) rescue Timeout::Error unknown_error "Cucumber exceeded the timeout of #{config[:timeout]} seconds" return end puts "Results: #{result[:results]}" if config[:debug] puts "Exit status: #{result[:exit_status]}" if config[:debug] unless [0, 1].include? result[:exit_status] unknown_error "Cucumber returned exit code #{result[:exit_status]}" return end results = JSON.parse(result[:results], symbolize_names: true) outcome = :ok scenario_count = 0 statuses = [:passed, :failed, :pending, :undefined] status_counts = {} statuses.each { |scenario_status| status_counts[scenario_status] = 0 } sensu_events = [] utc_timestamp = Time.now.getutc.to_i results.each do |feature| # #YELLOW Array(feature[:elements]).each do |element| if element[:type] == 'scenario' event_name = "#{config[:name]}.#{generate_name_from_scenario(feature, element)}" scenario_status = get_scenario_status(element) sensu_events << generate_sensu_event(event_name, feature, element, scenario_status) metrics = generate_metrics_from_scenario(feature, element, scenario_status, utc_timestamp) unless metrics.nil? sensu_events << generate_metric_event(event_name, metrics) end scenario_count += 1 status_counts[scenario_status] += 1 end end end puts "Sensu events: #{JSON.pretty_generate(sensu_events)}" if config[:debug] errors = raise_sensu_events(sensu_events) if errors.length > 0 outcome = :unknown elsif scenario_count == 0 outcome = :warning end data = { 'status' => outcome.to_s, 'scenarios' => scenario_count } statuses.each do |status| data[status.to_s] = status_counts[status] if status_counts[status] > 0 end data['errors'] = errors if errors.length > 0 data = dump_yaml(data) case outcome when :ok ok data when :warning warning data when :unknown unknown data end end def config_is_valid? if config[:name].nil? unknown_error 'No name specified' return false end if config[:handler].nil? unknown_error 'No handler specified' return false end if config[:metric_handler].nil? unknown_error 'No metric handler specified' return false end if config[:metric_prefix].nil? unknown_error 'No metric prefix specified' return false end if config[:command].nil? unknown_error 'No cucumber command line specified' return false end if config[:working_dir].nil? unknown_error 'No working directory specified' return false end config[:timeout] ||= INFINITE_TIMEOUT config[:timeout] = Float(config[:timeout]) unless config[:timeout].nil? config[:env] ||= {} config[:event_data] ||= {} config[:attachments] = 'true' if config[:attachments].nil? case config[:attachments] when 'true' config[:attachments] = true when 'false' config[:attachments] = false else unknown_error 'Attachments argument is not a valid boolean' return false end true end def remove_attachments_from_scenario(scenario) Array(scenario[:steps]).each do |step| step[:embeddings] = [] if step.key?(:embeddings) end end def generate_name_from_scenario(feature, scenario) name = scenario[:id] name += ";#{feature[:profile]}" if feature.key? :profile name = name.tr('.', '-') .tr(';', '.') .gsub(/[^a-zA-Z0-9\._-]/, '-') .gsub(/^\.+/, '') .gsub(/\.+$/, '') .gsub(/\.+/, '.') parts = [] name.split('.').each do |part| part = part.gsub(/^-+/, '') .gsub(/-+$/, '') .gsub(/-+/, '-') parts << part unless part.length == 0 end name = parts.join('.') name end def raise_sensu_events(sensu_events) errors = [] sensu_events.each do |sensu_event| data = escape_unicode_characters_in_json(sensu_event.to_json) begin send_sensu_event(data) rescue StandardError => error errors << { 'message' => "Failed to raise event #{sensu_event[:name]}", 'error' => { 'message' => error.message, 'backtrace' => error.backtrace } } end end errors end def generate_metrics_from_scenario(feature, scenario, scenario_status, utc_timestamp) metrics = [] if scenario_status == :passed scenario_duration = 0 if scenario.key?(:steps) has_step_durations = false scenario_metric_prefix = "#{config[:metric_prefix]}.#{generate_name_from_scenario(feature, scenario)}" scenario[:steps].each.with_index do |step, step_index| if step.key?(:result) && step[:result].key?(:duration) has_step_durations = true step_duration = step[:result][:duration] step_duration = step_duration metrics << "#{scenario_metric_prefix}.step-#{step_index + 1}.duration #{step_duration} #{utc_timestamp}" scenario_duration += step_duration end end if has_step_durations scenario_metrics = [ "#{scenario_metric_prefix}.duration #{scenario_duration} #{utc_timestamp}", "#{scenario_metric_prefix}.step-count #{scenario[:steps].length} #{utc_timestamp}" ] metrics.unshift scenario_metrics end end end metrics = if metrics.length == 0 nil else metrics.join("\n") end metrics end private def output(msg) puts msg end def execute_cucumber(env, command, working_dir, timeout) results = nil pipe = nil begin begin Timeout.timeout(timeout) do pipe = IO.popen(env, command, chdir: working_dir, external_encoding: Encoding::UTF_8) results = pipe.read end rescue Timeout::Error Process.kill 9, pipe.pid raise Timeout::Error, 'Cucumber timed out' end ensure pipe.close unless pipe.nil? end { results: results, exit_status: $CHILD_STATUS.exitstatus } end def send_sensu_event(data) socket = TCPSocket.new('127.0.0.1', 3030) socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1) index = 0 length = data.length while index < length bytes_sent = socket.send data[index..-1], 0 index += bytes_sent end socket.close end def generate_sensu_event(event_name, feature, scenario, scenario_status) scenario_clone = deep_dup(scenario) remove_attachments_from_scenario(scenario_clone) unless config[:attachments] feature_clone = deep_dup(feature) feature_clone[:elements] = [scenario_clone] scenario_results = [feature_clone] scenario_output = get_output_for_scenario(scenario, scenario_status) scenario_status_code = case scenario_status when :passed OK when :failed CRITICAL when :pending, :undefined WARNING end sensu_event = { name: event_name, handlers: [config[:handler]], status: scenario_status_code, output: scenario_output, results: scenario_results } config[:event_data].each do |key, value| if value =~ /^\A-?[0-9]+\z/ value = Integer(value) elsif value =~ /^\A-?[0-9]+\.[0-9]+\z/ value = Float(value) elsif value == 'true' value = true elsif value == 'false' value = false end sensu_event[key] = value end sensu_event end def generate_metric_event(event_name, metrics) metric_event = { name: "#{event_name}.metrics", type: 'metric', handlers: [config[:metric_handler]], output: metrics, status: 0 } metric_event end def get_scenario_status(scenario) scenario_status = :passed # #YELLOW Array(scenario[:steps]).each do |step| if step.key? :result step_status = step[:result][:status] if %w(failed pending undefined).include? step_status scenario_status = step_status.to_sym break end end end scenario_status end def get_output_for_scenario(scenario, scenario_status) steps_output = [] Array(scenario[:steps]).each_with_index do |step, index| has_result = step.key?(:result) step_status = has_result ? step[:result][:status] : 'UNKNOWN' step_output = { 'step' => "#{step_status.upcase} - #{index + 1} - #{step[:keyword]}#{step[:name]}" } if has_result && step[:result].key?(:error_message) step_output['error'] = step[:result][:error_message] end steps_output << step_output end scenario_output = { 'status' => scenario_status.to_s, 'steps' => steps_output } dump_yaml(scenario_output) end def unknown_error(message) data = { 'status' => 'unknown', 'errors' => [ { 'message' => message } ] } unknown dump_yaml(data) end def deep_dup(obj) Marshal.load(Marshal.dump(obj)) end def dump_yaml(data) data.to_yaml.gsub(/^---\r?\n/, '') end def escape_unicode_characters_in_json(json) json.unpack('U*').map { |i| i < 128 ? i.chr : "\\u#{i.to_s(16).rjust(4, '0')}" }.join end end