Frank Mitchell

Get email when Chef runs fail

One of my favorite parts of Jenkins is email notifications. You get email both when a job fails, and when it goes back to being normal. Mimicing this functionality in Chef is a matter of using exception and report handlers to send email when a run fails or finishes.

Writing your own handler

Building a custom Chef hanlder is a matter of inheriting from the Chef::Handler class and overriding the report method.

module BeFrank
  class SendEmail < Chef::Handler
    def report
    end
  end
end

Though really you want to get email sending working before you worry about getting reporting information out of Chef. Because good programmers Google, Jerod Santo gets credit for figuring out how to send email and Gavin Kistner gets credit for unindenting HEREDOCs.

require 'net/smtp'

module BeFrank
  class SendEmail < Chef::Handler
    def report
    end

    private

    def send_email options = {}
      options[:subject] ||= 'Hello from Chef'
      options[:body] ||= '...'
      options[:from] ||= 'chef@example.com'
      options[:from_alias] ||= 'Chef Client'
      options[:to] ||= 'you@example.com'
      options[:server] ||= 'localhost'

      from = options[:from]
      to = options[:to]

      message = <<-EOM
      From: #{options[:from_alias]} <#{from}>
      To: #{to}
      Subject: #{options[:subject]}

      #{options[:body]}
      EOM

      message = unindent message

      ::Net::SMTP.start(options[:server]) do |smtp|
        smtp.send_message message, from, to
      end
    end

    def unindent string
      first = string[/\A\s*/]
      string.gsub /^#{first}/, ''
    end
  end
end

If Chef is queued to run every couple of minutes, broken nodes will start to spam you. Spam sucks. To avoid it, hash the email and only send it if it’s different from the last email you sent. You can use Chef’s cache to store your checksum.

require 'net/smtp'
require 'digest'

module BeFrank
  class SendEmail < Chef::Handler
    def report
    end

    private

    def send_new_email data = {}
      cache = Chef::Config[:file_cache_path]
      cache = ::File.join cache, 'last_run.digest'

      last_digest = nil
      if ::File.exists? cache
        last_digest = ::File.read cache
      end

      # This works around an issue in Ruby 1.8
      # where Hashes don't enumerate their values
      # in a guaranteed order.
      data = data.keys.sort.map do |k|
        [k, data[k]]
      end

      digest = ::Digest::SHA256.hexdigest data.to_s
      ::File.open(cache, 'w') do |io|
        io << digest
      end

      if digest != last_digest
        send_email ::Hash[data]
      end
    end

    # You don't really want to reread all that
    # email handling code, do you?
  end
end

Chef provides a failed? method to check the status of the run. Grab the exception and backtrace from a failed run and email them out. If the run’s successful, send out a message saying everything’s okay.

require 'net/smtp'
require 'digest'
require 'time'

module BeFrank
  class SendEmail < Chef::Handler
    def report
      now = ::Time.now.utc.iso8601
      name = node.name

      subject = "Good Chef run on #{name} @ #{now}"
      message = "It's all good."

      if failed?
        subject = "Bad Chef run on #{name} @ #{now}"
        message = [run_status.formatted_exception]
        message += ::Array(backtrace).join("\n")
      end

      send_new_email(
        :subject => subject,
        :body => message
      )
    end

    private

    # You don't really want to reread all that
    # email hashing and handling code, do you?
  end
end

Did you notice the abuse of explict naming for core Ruby classes? Some of the Chef code (like the file resource class) conflicts with the naming for core Ruby classes during a run. Prefixing them with :: avoids those errors.

Installing your handler

The chef_handler cookbook makes installing your custom handler easy. Write up a new default email-handler recipe, drop your email handling code in as a cookbook file resource, and you’re good to go.

include_recipe 'chef_handler'

handler_path = node['chef_handler']['handler_path']
handler = ::File.join handler_path, 'send_email'

cookbook_file "#{handler}.rb" do
  source 'send_emal.rb'
end

chef_handler 'BeFrank::SendEmail' do
  source handler
  action :enable
end

Just include your email-handler cookbook in your base role to start getting email messages about the status of Chef runs.

Taking things further

Notifications are useful for telling you when new nodes successfully come online, and when existing nodes break. Now that you know how to do it for email, you can push Chef run notifications into CloudWatch, Graphite, or your monitoring solution of choice.

To make getting started with your own notifications easier, the code’s available on GitHub. Thanks to Sean Carolan from Opscode for encouraging me to publish it.