At CustomInk, we recently made the switch to a new DNS provider. During the switch, we mass-imported records from our existing provider to our new provider. Did we get them all? Are they live yet? The immediate solution was to open up the terminal and fire off cURL requests, but there had to be a better way...
In this post/tutorial, I will show you how I created a test suite for our DNS records using RSpec. The solution is amazingly simple, and your specs will only be about 7 lines of code!
I don't often do this, but to demonstrate just how awesome these tests are going to be, here is a final example spec:
require 'spec_helper'
describe 'www.customink.com' do
expects ttl: 300, type: 'A', value: '1.2.3.4'
end
As with any Ruby-based project, there's some initial setup. Generate a new project and create a Gemfile
with the following content:
source :rubygems
gem 'rspec', '~> 2.11.0'
Don't forget to run the bundle
command to install the gems:
$ bundle
Next, create a folder named spec
in your project directory and then create a spec/spec_helper.rb
file with the following content:
require 'rspec'
require 'resolv'
require 'spec/macros/zones_macro'
RSpec.configure do |config|
config.tty = true
config.include ZonesMacro
end
There are a few things to cover here:
resolv
, which is a Ruby 1.9 library for resolving DNS queries (it's like dig
on steroids). Here is the official documentation for resolv.ZonesMacro
.tty
so that we get colored output on our CI server and include our ZoneMacro
.ZoneMacro
The ZoneMacro
is where the "hardest" of our logic will live. That being said, it's not very complicated.
Create the macros/zones_macro.rb
file and start with the "boiler-plate" macro:
module ZonesMacro
module ClassMethods
end
module InstanceMethods
end
def self.included(base)
base.extend(ClassMethods)
base.send :include, InstanceMethods
end
end
In the context of RSpec (no pun intended), ClassMethods
are things directly inside a describe
block, and InstanceMethods
are things inside an it
declaration.
If you look back at our earlier example, you can see that we actually want ClassMethods
for our expects
method:
def expects(expected = {})
matched_records = records.select do |record|
record.type == expected[:type].upcase &&
record.ttl == expected[:ttl].to_i &&
record.value.include?(expected[:value].upcase)
end
it "should have the correct DNS entry" do
matched_records.should_not be_empty
end
end
So what is records
? Well, we need to define it. Because records
exists outside an it
block, the declaration needs to go inside the ClassMethods
module:
private
def records
@records ||= begin
Timeout::timeout(1) {
Resolv::DNS.new.getresources(self.display_name, Resolv::DNS::Resource::IN::ANY)
}
rescue Timeout::Error => e
$stderr.puts "Connection timed out for #{self.display_name}"
[]
end
end
We add the Timeout::timeout
so that resources that cannot be reached timeout in a reasonable amount of time.
Most of that is directly out of the Resolv Documentation, except for the self.class.display_name
. This is provided by RSpec, and it's the value of the describe
block.
In other words:
describe 'This is the self.display_name' do
# ...
end
The Resolv
block will make a DNS query to the display_name
. Notice that we are returning an array of resources. You may also see a method called getresource
(singular) for Resolve::DNS
that returns only the first resource. If you have multiple records for the same key (like an A record and an MX record), your tests will not pass, because getresource
just grabs the first record it sees...
We are also searching for a resource_type
of ANY
. This doesn't really matter because we are caching the result, and we are also already checking the type of the record returned.
If you run this right now, you'll get a lot of undefined method
errors. That's because I also monkey-patched Resolv::DNS::Resource
to return normalized data:
class Resolv::DNS::Resource
def value
%w(address data exchange name target).collect do |key|
self.send(key.to_sym).to_s.upcase if self.respond_to?(key.to_sym)
end.compact
end
def type
self.class.name.split('::').last.upcase
end
end
We have 500+ DNS records, so manually writing all of those tests was also a daunting task. Of course, I scripted it. You can get an export file of your current configuration from your DNS provider. The format may vary, but here's the simple little script I wrote to automatically generate all our tests.
# `config` is the existing DNS configuration file
config = File.readlines('/Users/svargo/Desktop/config').
# For each line, split on a space, remove the drunk, and
# create a hash for easy access.
collect do |line|
split = line.split(' ').collect{ |l| l.strip.chomp('.') }
{ :url => split[0], :ttl => split[1], :type => split[3], :data => split[4] }
end
# For each record, write out the spec and expected results.
# I had to massage some data using `gsub` because of inconsistent formatting.
config.each do |record|
str = <<-EOH
require 'spec_helper'
describe '#{record[:url]}' do
expects ttl: #{record[:ttl]}, type: '#{record[:type]}', value: '#{record[:data].gsub('"', '')}'
end
EOH
# Write the spec to the proper file
File.open("spec/units/#{record[:url]}_spec.rb", 'w'){ |f| f.write(str) }
end
Needless to say, this saved a significant amount of time and energy.
I created a quick Jenkins job for these specs and set them to run every 15 minutes. Super simple and reliable CI for our DNS entires. Now, we can easily spot outdated records or problem servers.
While this solution is expandable, it's far from complete. That being said, do you really require 100% coverage for all your DNS entires? It was very useful to have 100% coverage here, because we were migrating from our existing provider entirely. If you are writing these specs as "health checks", you will probably have significantly less...
This is definitely not a replacement for solutions like New Relic or other monitoring software, but it's an excellent homegrown solution in under 2 hours.
This post was written using Ruby 1.9.3. It should work on Ruby 1.9.x, but I guarantee it won't work on 1.8.7.