When developing Chef cookbooks, a good test suite is an invaluable ally. It confers the power of confidence, confidence to refactor code or add new functionality and be…confident that you haven’t broken anything.
But when deciding how to test cookbooks, there is a certain amount of choice. Test Kitchen is a given, there really is no competition. But then do you run unit tests, integration tests, or both? Do you use Chefspec, Serverspec or Inspec? At Spaceape we have settled on writing unit tests only where they make sense, and concentrating on integration tests: we want to test the final state of servers running our cookbooks rather than necessarily how they get there. Serverspec has traditionally been our framework of choice but, following the lead of the good folks at Chef, we’ve recently started using Inspec.
Inspec is the natural successor to Serverspec. We already use it to test for security compliance against the CIS rulebook, so it makes sense for us to try and converge onto one framework. As such we’ve been writing our own custom Inspec resources and, with it being a relatively new field, wanted to share our progress.
The particular resource we’ll describe here is used to test our in-house Redis cookbook, sag_redis. It is a rather complex cookbook that actually uses information stored in Consul to build out Redis farms that register themselves with a Sentinel cluster. We’ll forego all that complexity here and just concentrate on how we go about testing the end state.
In the following example, we’ll be using Test Kitchen with the kitchen-vagrant plugin.
Directory Structure:
Within our sag_redis cookbook, we’ll create an inspec profile. This is a set of files that describe what should be tested, and how. The directory structure of an inspec profile is hugely important, if you deviate even slightly then the tests will fail to run. The best way to ensure compliance is to use the Inspec CLI, which is bundled with later versions of the Chef DK.
Create a directory test/integration
then run:
inspec init profile default
This will create an Inspec profile called ‘default’ consisting in a bunch of files, some of which can be unsentimentally culled (the example control.rb for instance). As a bare minimum, we need a structure that looks like this:
—test
└── integration
│ ├── default
│ │ ├── controls
│ │ ├── inspec.yml
│ │ └── libraries
The default inspec.yml will need to be changed, that should be self-evident. The controls directory will house our test specs, and the libraries directory is a good place to stick the custom resource we are about to write.
The Resource:
First, lets take a look at what an ‘ordinary’ Inspec matcher looks like:
describe user('redis') do it { should exist } its('uid') { should eq 1234 } its('gid') { should eq 1234 } end
Fairly self-explanatory and readable (which incidentally was one of the original goals of the Inspec project). The purpose of writing a custom resource is to bury a certain amount of complexity in a library, and expose it in the DSL as something akin to the above.
The resource we’ll write will be used to confirm that on-disk Redis configuration is as we expect. It will parse the config file and provide methods to check each of the options contained therein. In DSL it should look something like this:
describe redis_config('my_redis_service') do its('port') { should eq(6382) } its('az') { should eq('us-east-1b') } end
So, in the default/libraries
directory, we’ll create a file called redis_config.rb with the following contents:
class RedisConfig < Inspec.resource(1) name 'redis_config' desc ' Check Redis on-disk configuration. ' example ' describe redis_config('dummy_service_6') do its('port') { should eq('6382') } its('slave-priority') { should eq('69') } end ' def initialize(service) @service = service @path = "/etc/redis/#{service}" @file = inspec.file(@path) begin @params = Hash[*@file.content.split("\n") .reject{ |l| l =~ /^#/ or l =~ /^save/ } .collect { |v| [ v.chomp.split ] } .flatten] rescue StandardError return skip_resource "#{@file}: #{$!}" end end end def exists? @file.file? end def method_missing(name) @params[name.to_s] end end
There’s a fair bit going on here.
The resource is initialised with a single parameter – the name of the Redis service under test. From this we derive the @path
of the its on-disk configuration. We then use this @path
to initialise another Inspec resource: @file
.
Why do this, why not just use a common-or-garden ::File
object and be done with it? There is a good reason, and this is important: the test is run on the host machine, not the guest. If we were to use ::File
then Inspec would check the machine running Test Kitchen, not the VM being tested. By using the Inspec file resource, we ensure we are checking the file at the given path on the Vagrant VM.
The remainder of the initialize function is dedicated to parsing the on-disk Redis config into a hash (@params
) of attribute:value pairs. The ‘save’ lines that configure bgsync snapshotting are unique in that they have more than one value after the parameter name, so we ignore them. If we wanted to test these options we’d need to write a separate function.
The exists?
function acts on our Inspec file resource, returning a boolean. Through some Inspec DSL sleight-of-hand this allows us to use the matcher it { should exist }
(or indeed it { should_not exist }
).
The final function delegates all missing methods to the @params hash, so we are able to reference the config options directly as ‘port’ or ‘slave-priority’, for instance.
The Controls:
In Inspec parlance, the controls are where we describe the tests we wish to run.
In the interests of keeping it simple, we’ll write a single test case in default/controls/redis_configure_spec.rb
that looks like this:
describe redis_config(“leaderboard_service") do it { should exist } its('slave-priority') { should eq('50') } its('rdbcompression') { should eq('yes') } its('dbfilename') { should eq('leaderboard_service.rdb') } end
The Test:
Now we just need to instruct Test Kitchen to actually run the test.
The .kitchen.yml
file in the base of our sag_redis cookbook looks like this:
driver: name: vagrant require_chef_omnibus: 12.3.0 provision: true vagrantfiles: - vagrant.rb provisioner: name: chef_zero verifier: name: inspec platforms: - name: ubuntu-14.04 driver: box: ubuntu64-ami customize: memory: 1024 suites: - name: default provisioner: client_rb: environment: test run_list: - role[sag_redis_default]
Obviously this is quite subjective, but the important points to note are that we set the verifier
to be inspec
and we provide the name: default
to the particular suite we wish to test (recall that our Inspec profile is called ‘default’).
And thats it! Now we can just run kitchen test and our Inspec custom resource will check that our Redis services are configured as we expect.