Testing infrastructure with serverspec

Vincent Bernat

Check­ing if your servers are con­fig­ured cor­rectly can be done with IT au­to­ma­tion tools like Pup­pet, Chef, An­si­ble or Salt. They al­low an ad­min­is­tra­tor to spec­ify a tar­get con­fig­u­ra­tion and en­sure it is ap­plied. They can also run in a dry-run mode and re­port servers not match­ing the ex­pected con­fig­uration.

On the other hand, server­spec is a tool to bring RSpec, a test­ing tool for the Ruby pro­gram­ming lan­guage fre­quently used for test-driven de­vel­op­ment, to the in­fra­struc­ture world. It can be used to re­motely test server state through an SSH con­nection.

Why would one use such an ad­di­tional tool? Many things are eas­ier to ex­press with a test than with a con­fig­u­ra­tion change, like ver­i­fy­ing that a ser­vice is cor­rectly in­stalled by check­ing that it is lis­ten­ing on some port.

Getting started#

Good knowl­edge of Ruby may help but is not a pre­req­ui­site to the use of server­spec. Writ­ing tests feels like writ­ing what we ex­pect in plain Eng­lish. If you think you need to know more about Ruby, here are two short re­sources to get started:

server­spec’s home­page con­tains a short and con­cise tu­to­rial on how to get started. Please, read it. As a first il­lus­tra­tion, here is a test check­ing a ser­vice is cor­rectly lis­ten­ing on port 80:

describe port(80) do
  it { should be_listening }
end

The fol­low­ing test will spot servers still run­ning with De­bian Squeeze in­stead of De­bian Wheezy:

describe command("lsb_release -d") do
  it { should return_stdout /wheezy/ }
end

Con­di­tional tests are also pos­si­ble. For ex­am­ple, we want to check the miimon pa­ram­e­ter of bond0, but only when the in­ter­face is present:

has_bond0 = file('/sys/class/net/bond0').directory?

# miimon should be set to something other than 0, otherwise, no checks
# are performed.
describe file("/sys/class/net/bond0/bonding/miimon"), :if => has_bond0 do
  it { should be_file }
  its(:content) { should_not eq "0\n" }
end

server­spec comes with a com­plete doc­u­men­ta­tion of avail­able re­source types (like port and command) that can be used af­ter the key­word describe.

When a test is too com­plex to be ex­pressed with sim­ple ex­pec­ta­tions, it can be spec­i­fied with ar­bi­trary com­mands. In the be­low ex­am­ple, we check if memcached is con­fig­ured to use al­most all the avail­able sys­tem memory:

# We want memcached to use almost all memory. With a 2GB margin.
describe "memcached" do
  it "should use almost all memory" do
    total = command("vmstat -s | head -1").stdout # ❶
    total = /\d+/.match(total)[0].to_i
    total /= 1024
    args = process("memcached").args # ❷
    memcached = /-m (\d+)/.match(args)[1].to_i
    (total - memcached).should be > 0
    (total - memcached).should be < 2000
  end
end

A bit more ar­cane, but still un­der­stand­able: we com­bine ar­bi­trary shell com­mands (in ❶) and use of other server­spec re­source types (in ❷).

Advanced use#

Out of the box, server­spec pro­vides a strong foun­da­tion to build a com­pli­ance tool to be run on all sys­tems. It comes with some use­ful ad­vanced tips, like shar­ing tests among sim­i­lar hosts or ex­e­cut­ing sev­eral tests in parallel.

I have set up a GitHub repos­i­tory to be used as a tem­plate to get the fol­low­ing features:

  • as­sign roles to servers and tests to roles;
  • par­al­lel ex­ecution;
  • re­port gen­er­a­tion & viewer.

Host classification#

By de­fault, serverspec-init gen­er­ates a tem­plate where each host has its own di­rec­tory with its unique set of tests. server­spec only han­dles test ex­e­cu­tion on re­mote hosts: the test ex­e­cu­tion flow (which tests are ex­e­cuted on which servers) is del­e­gated to some Rakefile.1 In­stead of ex­tract­ing the list of hosts to test from a di­rec­tory hi­er­ar­chy, we can ex­tract it from a file (or from an LDAP server or from any source) and at­tach a set of roles to each of them:

hosts = File.foreach("hosts")
  .map { |line| line.strip }
  .map do |host|
  {
    :name => host.strip,
    :roles => roles(host.strip),
  }
end

The roles() func­tion should re­turn a list of roles for a given host­name. It could be some­thing as sim­ple as this:

def roles(host)
  roles = [ "all" ]
  case host
  when /^web-/
    roles << "web"
  when /^memc-/
    roles << "memcache"
  when /^lb-/
    roles << "lb"
  when /^proxy-/
    roles << "proxy"
  end
  roles
end

In the snip­pet be­low, we cre­ate a task for each server as well as a server:all task that will ex­e­cute the tests for all hosts (in ❶). Pay at­ten­tion, in ❷, at how we at­tach the roles to each server.

namespace :server do
  desc "Run serverspec to all hosts"
  task :all => hosts.map { |h| h[:name] } # ❶

  hosts.each do |host|
    desc "Run serverspec to host #{host[:name]}"
    ServerspecTask.new(host[:name].to_sym) do |t|
      t.target = host[:name]
      # ❷: Build the list of tests to execute from server roles
      t.pattern = './spec/{' + host[:roles].join(",") + '}/*_spec.rb'
    end
  end
end

You can check the list of tasks created:

$ rake -T
rake check:server:all      # Run serverspec to all hosts
rake check:server:web-10   # Run serverspec to host web-10
rake check:server:web-11   # Run serverspec to host web-11
rake check:server:web-12   # Run serverspec to host web-12

Then, you need to mod­ify spec/spec_helper.rb to tell server­spec to fetch the host to test from the en­vi­ron­ment vari­able TARGET_HOST in­stead of ex­tract­ing it from the spec file name.

Parallel execution#

By de­fault, each task is ex­e­cuted when the pre­vi­ous one has fin­ished. With many hosts, this can take some time. rake pro­vides the -j flag to spec­ify the num­ber of tasks to be ex­e­cuted in par­al­lel and the -m flag to ap­ply par­al­lel­ism to all tasks:

$ rake -j 10 -m check:server:all

Reports#

rspec is in­voked for each host. There­fore, the out­put is some­thing like this:

$ rake spec
env TARGET_HOST=web-10 /usr/bin/ruby -S rspec spec/web/apache2_spec.rb spec/all/debian_spec.rb
......

Finished in 0.99715 seconds
6 examples, 0 failures

env TARGET_HOST=web-11 /usr/bin/ruby -S rspec spec/web/apache2_spec.rb spec/all/debian_spec.rb
......

Finished in 1.45411 seconds
6 examples, 0 failures

This does not scale well if you have dozens or hun­dreds of hosts to test. More­over, the out­put is man­gled with par­al­lel ex­e­cu­tion. For­tu­nately, rspec comes with the abil­ity to save re­sults in JSON for­mat. These per-host re­sults can then be con­sol­i­dated into a sin­gle JSON file. All this can be done in the Rakefile:

  1. For each task, set rspec_opts to --format json --out ./reports/current/#{target}.json. This is done au­to­mat­i­cally by the sub­class ServerspecTask which also han­dles pass­ing the host­name in an en­vi­ron­ment vari­able and a more con­cise and col­ored output.

  2. Add a task to col­lect the gen­er­ated JSON files into a sin­gle re­port. The test source code is also em­bed­ded in the re­port to make it self-suf­fi­cient. More­over, this task is ex­e­cuted au­to­mat­i­cally by adding it as a de­pen­dency of the last server­spec-re­lated task.

Have a look at the com­plete Rakefile for more de­tails on how this is done.

A very sim­ple web-based viewer can han­dle these re­ports.2 It shows the test re­sults as a ma­trix with failed tests in red:

Report viewer example
Short report example as displayed by the viewer

Click­ing on any test will dis­play the nec­es­sary in­for­ma­tion to trou­bleshoot er­rors, in­clud­ing the test short de­scrip­tion, the com­plete test code, the ex­pec­ta­tion mes­sage and the backtrace:

Report viewer showing detailed error
A failed test displayed in the viewer

I hope this ad­di­tional layer will help make server­spec an­other feather in the “IT” cap, be­tween an au­to­ma­tion tool and a su­per­vi­sion tool.