Automated Puppet Testing (Pt. II)
How to get started with unit testing Puppet code, part 2 of 3.
Introduction
This post follows on from a previous post. If you’re new to testing your Puppet code, go there to get set up! We’ll be using the same repository and building from there.
This part of the tutorial will focus on Facts and Fixtures. Briefly:
- Facts are information provided by the Agent during a puppet run. Generally they’re information derived from the box itself, things such as IP addresses, fully qualified domain names, OS versions, etc. Sometimes you want to define some custom facts for your specific module, either to make logic easier in templates/code, or to provide information back to an External Node Classifier such as Foreman or RedHat Satellite.
- Fixtures are a testing tool: your module may have dependencies, but when running the RSpec tests not all of these will be available. We tell RSpec how to pull these dependencies so that the tests run correctly.
We’ll tackle these in reverse order!
Fixtures
I’ll work with an example from my system hardening module
“toughen” and we’ll flesh it out
in the helloworld
repo.
There’s a section of the system hardening module where I want to add a specific line to a config file for Postfix. I don’t want to have to manage the entire file, especially when Postfix may not even be installed! Instead, I just want to make sure that (unless overridden) Postfix is listening to local interfaces only by default. This means if the package gets pulled in as a dependency or is installed as part of a default build, there’s not an extra port listening on the network that could expose an attack vector.
Specifically, I want to ensure that this line is present:
inet_interfaces = localhost
That’s it! The PuppetLabs stdlib
module provides an excellent tool for this.
We could probably use augeas to do it (and the excellent providers from the
herculesteam would be perfect for it), but we’ll
just use stdlib
. There’s a lot of other good tools in there: go read the
docs!
Lucky for us, the stdlib
module is a dependency by default in the
metadata.json
, so no need to update that. You would if you were adding
additional dependencies. So, similar to Part 1, we’ll start by writing the
tests.
Test Case
Create a file call spec/classes/postfix_spec.rb
:
require 'spec_helper'
describe 'helloworld::postfix' do
context 'with default values for all parameters' do
it { should contain_file_line('postfix-local-only') }
end
end
Save and quit, and run bundle exec rake test
. You might want to alias that:
alias bert='bundle exec rake test'
. As expected, failure:
Finished in 0.16792 seconds (files took 0.67494 seconds to load)
3 examples, 1 failure
Failed examples:
rspec ./spec/classes/postfix_spec.rb:4 # helloworld::postfix with default values for all parameters should contain File_line[postfix-local-only]
So, now we go write some code to back it up.
Class Under Test
Create a file manifests/postfix.pp
:
# Class: Helloworld::Postfix
#
class helloworld::postfix {
file_line { 'postfix-local-only':
path => '/etc/postfix/main.cf',
line => 'inet_interfaces = localhost',
match => '^inet_interface',
}
}
This is pretty bare. Basically all it’s saying is that in the file
/etc/postfix/main.cf
, the line specified needs to be present. We can help
puppet identify that line by specifying a regex: that’s the match
parameter.
It’s best to keep those simple if you’re using them.
Now, run the tests again:
Finished in 0.16711 seconds (files took 0.62496 seconds to load)
3 examples, 1 failure
Failed examples:
rspec ./spec/classes/postfix_spec.rb:4 # helloworld::postfix with default values for all parameters should contain File_line[postfix-local-only]
Huh, failed again…
Adding the Fixture
Scrolling further up:
Failures:
1) helloworld::postfix with default values for all parameters should contain File_line[postfix-local-only]
Failure/Error: it { should contain_file_line('postfix-local-only') }
Puppet::PreformattedError:
Evaluation Error: Error while evaluating a Resource Statement, Unknown resource type: 'file_line' at /home/shearna/repos/helloworld/spec/fixtures/modules/helloworld/manifests/postfix.pp:4:3 on node boris-shearna.home
Okay, that makes more sense. What it’s saying is that the test hasn’t passed
because the resource type file_line
isn’t available. That’s because we need
to tell RSpec there’s a dependency! First, create a file in the root of your
repository called .fixtures.yml
. Mine looks like this:
fixtures:
repositories:
stdlib: "git://github.com/puppetlabs/puppetlabs-stdlib.git"
You can also specify a particular version of the repo if you like:
fixtures:
repositories:
stdlib:
repo: "git://github.com/puppetlabs/puppetlabs-stdlib.git"
ref: "4.17.0"
Useful if you’re worried about compatibility (even more so now Puppet 3 is officially deprecated/end-of-life’d/etc).
Okay, with that done, let’s try our tests again:
shearna@boris-shearna:~/repos/helloworld$ bert
Warning: Dependency puppetlabs-stdlib has an open ended dependency version requirement >= 1.0.0
---> syntax:manifests
---> syntax:templates
---> syntax:hiera:yaml
puppet parser validate --noop manifests/postfix.pp
puppet parser validate --noop manifests/init.pp
ruby -c spec/spec_helper.rb
Syntax OK
ruby -c spec/classes/init_spec.rb
Syntax OK
ruby -c spec/classes/postfix_spec.rb
Syntax OK
Cloning into 'spec/fixtures/modules/stdlib'...
remote: Counting objects: 563, done.
remote: Compressing objects: 100% (501/501), done.
remote: Total 563 (delta 160), reused 207 (delta 44), pack-reused 0
Receiving objects: 100% (563/563), 277.30 KiB | 0 bytes/s, done.
Resolving deltas: 100% (160/160), done.
Checking connectivity... done.
/usr/bin/ruby2.3 -I/home/shearna/repos/helloworld/vendor/bundle/ruby/2.3.0/gems/rspec-core-3.6.0/lib:/home/shearna/repos/helloworld/vendor/bundle/ruby/2.3.0/gems/rspec-support-3.6.0/lib /home/shearna/repos/helloworld/vendor/bundle/ruby/2.3.0/gems/rspec-core-3.6.0/exe/rspec --pattern spec/\{aliases,classes,defines,unit,functions,hosts,integration,type_aliases,types\}/\*\*/\*_spec.rb --color
...
Finished in 0.1726 seconds (files took 0.64383 seconds to load)
3 examples, 0 failures
Excellent! We have working fixtures! You can use this to add any other dependencies to your project, so if you’re going the whole way and writing tests for your roles and profiles, then this would be especially useful.
Let’s move on to facts…
Facts
Now what if we wanted to only add that file line if the file was present? If
for some reason the postfix
package was not installed on the system, this
module would fail at runtime, as the file /etc/postfix/main.cf
wouldn’t be
present. One way I’ve approached this in my module is to add some simple facts
to indicate whether the package is installed. This lets me guard the resource
declaration with a simple if
statement.
Writing a Custom Fact
As before, we’ll write our tests first. Create a file called
spec/unit/facter/postfix_installed_spec.rb
- you may need to create some
folders here! The file:
describe 'postfix_installed', :type => :fact do
before { Facter.clear }
after { Facter.clear }
context "on linux" do
let (:facts) { {:kernel => 'Linux' } }
it "should return true if installed" do
Facter::Util::Resolution.stubs(:which).with('sendmail').returns('/sbin/sendmail')
expect(Facter.fact(:postfix_installed).value).to eq(true)
end
it "should return false if not installed" do
Facter::Util::Resolution.stubs(:which).with('sendmail').returns(nil)
expect(Facter.fact(:postfix_installed).value).to eq(false)
end
end
end
Okay, lets go through this first:
- We’re describing the
postfix_installed
object, and it’s afact
. - We clear the current set of facts on each test run.
- We specify our default context (in this case, only Linux machines)
- We specify some facts for testing purposes, in case we’re developing on another kernel.
- Then we actually test our fact. As you’ll see in the next bit, we use the
Facter::Util::Resolution
methods to make it easy to mock our fact, which is what we’re doing here. We mock a call towhich
with the argumentsendmail
, and we tell the call what to return. Here we’re checking that thesendmail
command (provided by thepostfix
package) is present, which is nice and simple. - We then check the fact is set correctly.
- Lastly, we do a similar test for the other logical branch where the package is not installed.
We’ll run the tests and see what the failure is:
Failures:
1) postfix_installed on linux should return true if installed
Failure/Error: expect(Facter.fact(:postfix_installed).value).to eq(true)
NoMethodError:
undefined method `value' for nil:NilClass
# ./spec/unit/facter/postfix_installed_spec.rb:9:in `block (3 levels) in <top (required)>'
2) postfix_installed on linux should return false if not installed
Failure/Error: expect(Facter.fact(:postfix_installed).value).to eq(false)
NoMethodError:
undefined method `value' for nil:NilClass
# ./spec/unit/facter/postfix_installed_spec.rb:14:in `block (3 levels) in <top (required)>'
Finished in 0.51381 seconds (files took 0.64751 seconds to load)
5 examples, 2 failures
Failed examples:
rspec ./spec/unit/facter/postfix_installed_spec.rb:7 # postfix_installed on linux should return true if installed
rspec ./spec/unit/facter/postfix_installed_spec.rb:12 # postfix_installed on linux should return false if not installed
Okay, no surprises there, we’ve not written any code. Add the following to
lib/facter/postfix_installed.rb
(again, you may need to create the folder):
# Returns true if postfix is installed
Facter.add(:postfix_installed) do
confine :kernel => 'Linux'
setcode do
output = Facter::Util::Resolution.which('sendmail')
output ? true : false
end
end
Nice and simple! We define a fact called postfix_installed
, and specify that
it’s only valid on the Linux kernel. Then we use the Facter::Util::Resolution
class to provide an easily-testable variable called output
. If output
has
any value at all, the fact is true
, otherwise false
. This works because the
which
command returns nothing except an error code if the command isn’t
found.
If we run the test again, we’ll see:
/usr/bin/ruby2.3 -I/home/shearna/repos/helloworld/vendor/bundle/ruby/2.3.0/gems/rspec-core-3.6.0/lib:/home/shearna/repos/helloworld/vendor/bundle/ruby/2.3.0/gems/rspec-support-3.6.0/lib /home/shearna/repos/helloworld/vendor/bundle/ruby/2.3.0/gems/rspec-core-3.6.0/exe/rspec --pattern spec/\{aliases,classes,defines,unit,functions,hosts,integration,type_aliases,types\}/\*\*/\*_spec.rb --color
.....
Finished in 0.20792 seconds (files took 0.63526 seconds to load)
5 examples, 0 failures
Voila! Working custom fact. We can now use this in our class.
Using the Fact and Updating Tests
Modify the helloworld::postfix
class to look like this:
# Class: Helloworld::Postfix
#
class helloworld::postfix {
if $::postfix_installed {
file_line { 'postfix-local-only':
path => '/etc/postfix/main.cf',
line => 'inet_interfaces = localhost',
match => '^inet_interface',
}
}
}
Note the if
statement wrapping the declaration. If we run the tests now, it
may well fail as the value of the fact while under test probably isn’t defined:
Finished in 0.18349 seconds (files took 0.6455 seconds to load)
5 examples, 1 failure
Failed examples:
rspec ./spec/classes/postfix_spec.rb:4 # helloworld::postfix with default values for all parameters should contain File_line[postfix-local-only]
In order to test the class, we’ll need to add some logic around the tests.
We’ll update the test class postfix_spec.rb
, removing the simple test that
was there previously and adding 2 to replace it:
require 'spec_helper'
describe 'helloworld::postfix' do
context 'with postfix installed' do
let (:facts) do { :postfix_installed => true } end
it { should { contain_file_line('postfix-local-only') } }
end
context 'without postfix installed' do
let (:facts) do { :postfix_installed => false } end
it { should_not { contain_file_line('postfix-local-only') } }
end
end
We’ve added some additional tests to confirm that it compiles with no changes, and 2 tests to check each value of the fact. If we run the tests now:
/usr/bin/ruby2.3 -I/home/shearna/repos/helloworld/vendor/bundle/ruby/2.3.0/gems/rspec-core-3.6.0/lib:/home/shearna/repos/helloworld/vendor/bundle/ruby/2.3.0/gems/rspec-support-3.6.0/lib /home/shearna/repos/helloworld/vendor/bundle/ruby/2.3.0/gems/rspec-core-3.6.0/exe/rspec --pattern spec/\{aliases,classes,defines,unit,functions,hosts,integration,type_aliases,types\}/\*\*/\*_spec.rb --color
......
Finished in 0.15009 seconds (files took 0.64442 seconds to load)
6 examples, 0 failures
Cool - all working!
Summary
So, we’ve now got a module that has basic tests wrapping up the logic of the classes, as well as a custom fact that gets tested, and some dependencies that get pulled in. The ideas here can be taken much further. Something to bear in mind is what you’re trying to test with these unit tests: you should be testing the logic of your class, making sure that the resources and parameters you’ve defined and guarded with certain bits of logic are correctly applied. There’s no need to test that every single resource is present, as that’s just testing that Puppet itself works as intended: something you should be able to assume works fine! Ideally, anywhere you have a variable that affects a resource or even a template, you should have a test for each branch of the logic tree.
If you plan to support multiple operating systems, then you’d want to expand your test contexts to cover the various OS’s that you support. In this way you can ensure that changes don’t break the module, a bit more easily than spinning up a whole load of vagrant boxes. We’ll cover that (system testing on VMs) in Part III!
./A