sensu-plugins/sensu-plugins-jenkins

View on GitHub
bin/check-jenkins-build-time.rb

Summary

Maintainability
A
3 hrs
Test Coverage
#!/usr/bin/env ruby
#
#   check-jenkins-build-time
#
# DESCRIPTION:
#   Alert if the last successful build timestamp of a jenkins job is older than
#   a specified time duration
#   OR not within a specific daily time window
#   OR if the total build duration exceeds a specified duration.
#
# OUTPUT:
#   plain text
#
# PLATFORMS:
#   Linux
#
# USAGE:
#   check-jenkins-build-time -u JENKINS_URL -j job_1_name=30min,job_2_name=1:30am-2:30am
#
#   -j parameter should be a comma-separated list of JOB_NAME=TIME_EXPRESSION
#   where TIME_EXPRESSION is either a relative time duration from now (30m, 1h) or
#   a daily time window (1am-2am, 1:01am-2:01am), without spaces.
#
#   --check-build-duration to check the last build duration for a job.
#
# DEPENDENCIES:
#   gem: sensu-plugin
#   jenkins_api_client
#   chronic_duration
#
#
# LICENSE:
#   Copyright Matt Greensmith  mgreensmith@cozy.co  matt@mattgreensmith.net
#   Released under the same terms as Sensu (the MIT license); see LICENSE
#   for details.
#

require 'sensu-plugin/check/cli'
require 'jenkins_api_client'
require 'chronic_duration'

class JenkinsBuildTime < Sensu::Plugin::Check::CLI
  ONE_DAY = 86_400

  option :url,
         description: 'URL to Jenkins API',
         short: '-u JENKINS_URL',
         long: '--url JENKINS_URL',
         required: true

  option :jobs,
         description: 'Jobs to check. Comma-separated list of JOB_NAME=TIME_EXPRESSION',
         short: '-j JOB_NAME=TIME_EXPRESSION,[JOB_NAME=TIME_EXPRESSION]',
         long: '--jobs JOB_NAME=TIME_EXPRESSION,[JOB_NAME=TIME_EXPRESSION]',
         required: true

  option :check_build_duration,
         description: 'Mode to check the build duration instead of the last occurence of a build',
         short: '-d',
         long: '--[no-]check-build-duration'

  option :username,
         description: 'Username for Jenkins instance',
         short: '-U USERNAME',
         long: '--username USERNAME',
         required: false

  option :password,
         description: "Password for Jenkins instance. Either set ENV['JENKINS_PASS'] or provide it as an option",
         short: '-p PASSWORD',
         long: '--password PASSWORD',
         required: false,
         default: ENV['JENKINS_PASS']

  def run
    @now = Time.now
    critical_jobs = []

    jobs = parse_jobs_param
    check_build_duration = config[:check_build_duration] || false

    jobs.each do |job_name, time_expression|
      begin
        build_number = last_successful_build_number(job_name)
        last_build_time = build_time(job_name, build_number)
        last_build_duration = build_duration(job_name, build_number)
      rescue
        critical "Error looking up Jenkins job: #{job_name}"
      end

      if check_build_duration
        unless time_within_allowed_build_duration?(last_build_duration,
                                                   parse_duration_seconds(time_expression))
          critical_jobs << critical_message_build_duration(job_name, last_build_time, last_build_duration, time_expression)
        end
      elsif time_expression_is_window?(time_expression)
        unless time_within_window?(last_build_time,
                                   parse_window_start(time_expression),
                                   parse_window_end(time_expression))
          critical_jobs << critical_message(job_name, last_build_time, last_build_duration, time_expression)
        end
      else
        unless time_within_allowed_duration?(last_build_time,
                                             parse_duration_seconds(time_expression))
          critical_jobs << critical_message(job_name, last_build_time, last_build_duration, time_expression)
        end
      end
    end

    critical "#{critical_jobs.length} failure(s): #{critical_jobs.join(', ')}" unless critical_jobs.empty?
    ok "#{jobs.keys.length} job(s) had successful builds within allowed times"
  end

  private

  def jenkins
    @jenkins ||= JenkinsApi::Client.new(server_url: config[:url], username: config[:username], password: config[:password], log_level: 3)
  end

  def last_successful_build_number(job_name)
    jenkins.job.list_details(job_name)['lastSuccessfulBuild']['number']
  end

  def build_details(job_name, build_number)
    # Cache the results
    @build_details ||= {}
    @build_details[job_name] ||= {}
    @build_details[job_name][build_number] ||= jenkins.job.get_build_details(job_name, build_number)
  end

  def build_time(job_name, build_number)
    # Jenkins expresses timestamps in epoch millis
    Time.at(build_details(job_name, build_number)['timestamp'] / 1000)
  end

  def build_duration(job_name, build_number)
    # Jenkins expresses timestamps in epoch millis, convert it to seconds
    build_details(job_name, build_number)['duration'] / 1000
  end

  def time_expression_is_window?(time_expression)
    time_expression.index('-') ? true : false
  end

  def parse_window_start(time_expression)
    Time.parse(time_expression.split('-')[0])
  end

  def parse_window_end(time_expression)
    Time.parse(time_expression.split('-')[1])
  end

  def time_within_window?(time, window_start, window_end)
    time_after_window_start_today = window_start < time
    time_in_window_today = time_after_window_start_today && window_end > time

    time_after_window_start_yesterday = (window_start - ONE_DAY) < time
    time_in_window_yesterday = time_after_window_start_yesterday && (window_end - ONE_DAY) > time

    time_ok = if @now < window_end && @now > window_start # we are in the window, so we will accept today or yesterday
                (time_in_window_today || time_in_window_yesterday)
              elsif @now < window_end
                # we are before the window, so we only accept yesterday
                time_in_window_yesterday
              else
                # we are after the window, so we only accept today
                time_in_window_today
              end
    time_ok
  end

  def parse_duration_seconds(time_expression)
    ChronicDuration.parse(time_expression)
  end

  def time_within_allowed_duration?(time, duration_seconds)
    time > (@now - duration_seconds)
  end

  def time_within_allowed_build_duration?(build_duration_seconds, duration_seconds)
    build_duration_seconds <= duration_seconds
  end

  def critical_message_build_duration(job_name, last_build_time, duration_seconds, time_expression)
    "#{job_name}: last built at #{last_build_time} (#{ChronicDuration.output(duration_seconds)}) exceeded max duration (#{time_expression})"
  end

  def critical_message(job_name, last_build_time, duration_seconds, time_expression)
    "#{job_name}: last built at #{last_build_time} (#{ChronicDuration.output(duration_seconds)}), not within allowed time: #{time_expression}"
  end

  def parse_jobs_param
    jobs = {}
    job_param = config[:jobs].split(',')
    job_param.each do |j|
      name, time_expression = j.split('=')
      raise "Jobs mut be expressed as JOB_NAME=TIME_EXPRESSION. Invalid parameter: '#{j}'" if time_expression.nil?
      jobs[name] = time_expression
    end
    jobs
  end
end