Extending Puppet using types, providers, facts and functions are well known and widely done. Something new is how to add entire new data types to the Puppet DSL to create entirely new language behaviours.
I’ve done a bunch of this recently with the Choria Playbooks and some other fun experiments, today I’ll walk through building a small network wide spec system using the Puppet DSL.
Overview
A quick look at what we want to achieve here, I want to be able to do Choria RPC requests and assert their outcomes, I want to write tests using the Puppet DSL and they should run on a specially prepared environment. In my case I have a AWS environment with CentOS, Ubuntu, Debian and Archlinux machines:
Below I test the File Manager Agent:
- Get status for a known file and make sure it finds the file
- Create a brand new file, ensure it reports success
- Verify that the file exist and is empty using the status action
cspec::suite("filemgr agent tests", $fail_fast, $report) |$suite| { # Checks an existing file $suite.it("Should get file details") |$t| { $results = choria::task("mcollective", _catch_errors => true, "action" => "filemgr.status", "nodes" => $nodes, "silent" => true, "fact_filter" => ["kernel=Linux"], "properties" => { "file" => "/etc/hosts" } ) $t.assert_task_success($results) $results.each |$result| { $t.assert_task_data_equals($result, $result["data"]["present"], 1) } } # Make a new file and check it exists $suite.it("Should support touch") |$t| { $fname = sprintf("/tmp/filemgr.%s", strftime(Timestamp(), "%s")) $r1 = choria::task("mcollective", _catch_errors => true, "action" => "filemgr.touch", "nodes" => $nodes, "silent" => true, "fact_filter" => ["kernel=Linux"], "fail_ok" => true, "properties" => { "file" => $fname } ) $t.assert_task_success($r1) $r2 = choria::task("mcollective", _catch_errors => true, "action" => "filemgr.status", "nodes" => $nodes, "silent" => true, "fact_filter" => ["kernel=Linux"], "properties" => { "file" => $fname } ) $t.assert_task_success($r2) $r2.each |$result| { $t.assert_task_data_equals($result, $result["data"]["present"], 1) $t.assert_task_data_equals($result, $result["data"]["size"], 0) } } } |
I also want to be able to test other things like lets say discovery:
cspec::suite("${method} discovery method", $fail_fast, $report) |$suite| { $suite.it("Should support a basic discovery") |$t| { $found = choria::discover( "discovery_method" => $method, ) $t.assert_equal($found.sort, $all_nodes.sort) } } |
So we want to make a Spec like system that can drive Puppet Plans (aka Choria Playbooks) and do various assertions on the outcome.
We want to run it with mco playbook run and it should write a JSON report to disk with all suites, cases and assertions.
Adding a new Data Type to Puppet
I’ll show how to add the Cspec::Suite data Type to Puppet. This comes in 2 parts: You have to describe the Type that is exposed to Puppet and you have to provide a Ruby implementation of the Type.
Describing the Objects
Here we create the signature for Cspec::Suite:
# modules/cspec/lib/puppet/datatypes/cspec/suite.rb Puppet::DataTypes.create_type("Cspec::Suite") do interface <<-PUPPET attributes => { "description" => String, "fail_fast" => Boolean, "report" => String }, functions => { it => Callable[[String, Callable[Cspec::Case]], Any], } PUPPET load_file "puppet_x/cspec/suite" implementation_class PuppetX::Cspec::Suite end |
As you can see from the line of code cspec::suite(“filemgr agent tests”, $fail_fast, $report) |$suite| {….} we pass 3 arguments: a description of the test, if the test should fail immediately on any error or keep going and there to write the report of the suite to. This corresponds to the attributes here. A function that will be shown later takes these and make our instance.
We then have to add our it() function which again takes a description and yields out `Cspec::Case`, it returns any value.
When Puppet needs the implementation of this code it will call the Ruby class PuppetX::Cspec::Suite.
Here is the same for the Cspec::Case:
# modules/cspec/lib/puppet/datatypes/cspec/case.rb Puppet::DataTypes.create_type("Cspec::Case") do interface <<-PUPPET attributes => { "description" => String, "suite" => Cspec::Suite }, functions => { assert_equal => Callable[[Any, Any], Boolean], assert_task_success => Callable[[Choria::TaskResults], Boolean], assert_task_data_equals => Callable[[Choria::TaskResult, Any, Any], Boolean] } PUPPET load_file "puppet_x/cspec/case" implementation_class PuppetX::Cspec::Case end |
Adding the implementation
The implementation is a Ruby class that provide the logic we want, I won’t show the entire thing with reporting and everything but you’ll get the basic idea:
# modules/cspec/lib/puppet_x/cspec/suite.rb module PuppetX class Cspec class Suite # Puppet calls this method when it needs an instance of this type def self.from_asserted_hash(description, fail_fast, report) new(description, fail_fast, report) end attr_reader :description, :fail_fast def initialize(description, fail_fast, report) @description = description @fail_fast = !!fail_fast @report = report @testcases = [] end # what puppet file and line the Puppet DSL is on def puppet_file_line fl = Puppet::Pops::PuppetStack.stacktrace[0] [fl[0], fl[1]] end def outcome { "testsuite" => @description, "testcases" => @testcases, "file" => puppet_file_line[0], "line" => puppet_file_line[1], "success" => @testcases.all?{|t| t["success"]} } end # Writes the memory state to disk, see outcome above def write_report # ... end def run_suite Puppet.notice(">>>") Puppet.notice(">>> Starting test suite: %s" % [@description]) Puppet.notice(">>>") begin yield(self) ensure write_report end Puppet.notice(">>>") Puppet.notice(">>> Completed test suite: %s" % [@description]) Puppet.notice(">>>") end def it(description, &blk) require_relative "case" t = PuppetX::Cspec::Case.new(self, description) t.run(&blk) ensure @testcases << t.outcome end end end end |
And here is the Cspec::Case:
# modules/cspec/lib/puppet_x/cspec/case.rb module PuppetX class Cspec class Case # Puppet calls this to make instances def self.from_asserted_hash(suite, description) new(suite, description) end def initialize(suite, description) @suite = suite @description = description @assertions = [] @start_location = puppet_file_line end # assert 2 things are equal and show sender etc in the output def assert_task_data_equals(result, left, right) if left == right success("assert_task_data_equals", "%s success" % result.host) return true end failure("assert_task_data_equals: %s" % result.host, "%s\n\n\tis not equal to\n\n %s" % [left, right]) end # checks the outcome of a choria RPC request and make sure its fine def assert_task_success(results) if results.error_set.empty? success("assert_task_success:", "%d OK results" % results.count) return true end failure("assert_task_success:", "%d failures" % [results.error_set.count]) end # assert 2 things are equal def assert_equal(left, right) if left == right success("assert_equal", "values matches") return true end failure("assert_equal", "%s\n\n\tis not equal to\n\n %s" % [left, right]) end # the puppet .pp file and line Puppet is on def puppet_file_line fl = Puppet::Pops::PuppetStack.stacktrace[0] [fl[0], fl[1]] end # show a OK message, store the assertions that ran def success(what, message) @assertions << { "success" => true, "kind" => what, "file" => puppet_file_line[0], "line" => puppet_file_line[1], "message" => message } Puppet.notice("✔๏ธ %s: %s" % [what, message]) end # show a Error message, store the assertions that ran def failure(what, message) @assertions << { "success" => false, "kind" => what, "file" => puppet_file_line[0], "line" => puppet_file_line[1], "message" => message } Puppet.err("โ %s: %s" % [what, @description]) Puppet.err(message) raise(Puppet::Error, "Test case %s fast failed: %s" % [@description, what]) if @suite.fail_fast end # this will show up in the report JSON def outcome { "testcase" => @description, "assertions" => @assertions, "success" => @assertions.all? {|a| a["success"]}, "file" => @start_location[0], "line" => @start_location[1] } end # invokes the test case def run Puppet.notice("==== Test case: %s" % [@description]) # runs the puppet block yield(self) success("testcase", @description) end end end end |
Finally I am going to need a little function to create the suite – cspec::suite function, it really just creates an instance of PuppetX::Cspec::Suite for us.
# modules/cspec/lib/puppet/functions/cspec/suite.rb Puppet::Functions.create_function(:"cspec::suite") do dispatch :handler do param "String", :description param "Boolean", :fail_fast param "String", :report block_param return_type "Cspec::Suite" end def handler(description, fail_fast, report, &blk) suite = PuppetX::Cspec::Suite.new(description, fail_fast, report) suite.run_suite(&blk) suite end end |
Bringing it together
So that’s about it, it’s very simple really the code above is pretty basic stuff to achieve all of this, I hacked it together in a day basically.
Lets see how we turn these building blocks into a test suite.
I need a entry point that drives the suite – imagine I will have many different plans to run, one per agent and that I want to do some pre and post run tasks etc.
plan cspec::suite ( Boolean $fail_fast = false, Boolean $pre_post = true, Stdlib::Absolutepath $report, String $data ) { $ds = { "type" => "file", "file" => $data, "format" => "yaml" } # initializes the report cspec::clear_report($report) # force a puppet run everywhere so PuppetDB is up to date, disables Puppet, wait for them to finish if $pre_post { choria::run_playbook("cspec::pre_flight", ds => $ds) } # Run our test suite choria::run_playbook("cspec::run_suites", _catch_errors => true, ds => $ds, fail_fast => $fail_fast, report => $report ) .choria::on_error |$err| { err("Test suite failed with a critical error: ${err.message}") } # enables Puppet if $pre_post { choria::run_playbook("cspec::post_flight", ds => $ds) } # reads the report from disk and creates a basic overview structure cspec::summarize_report($report) } |
Here’s the cspec::run_suites Playbook that takes data from a Choria data source and drives the suite dynamically:
plan cspec::run_suites ( Hash $ds, Boolean $fail_fast = false, Stdlib::Absolutepath $report, ) { $suites = choria::data("suites", $ds) notice(sprintf("Running test suites: %s", $suites.join(", "))) choria::data("suites", $ds).each |$suite| { choria::run_playbook($suite, ds => $ds, fail_fast => $fail_fast, report => $report ) } } |
And finally a YAML file defining the suite, this file describes my AWS environment that I use to do integration tests for Choria and you can see there’s a bunch of other tests here in the suites list and some of them will take data like what nodes to expect etc.
suites: - cspec::discovery - cspec::choria - cspec::agents::shell - cspec::agents::process - cspec::agents::filemgr - cspec::agents::nettest choria.version: mcollective plugin 0.7.0 nettest.fqdn: puppet.choria.example.net nettest.port: 8140 discovery.all_nodes: - archlinux1.choria.example.net - centos7.choria.example.net - debian9.choria.example.net - puppet.choria.example.net - ubuntu16.choria.example.net discovery.mcollective_nodes: - archlinux1.choria.example.net - centos7.choria.example.net - debian9.choria.example.net - puppet.choria.example.net - ubuntu16.choria.example.net discovery.filtered_nodes: - centos7.choria.example.net - puppet.choria.example.net discovery.fact_filter: operatingsystem=CentOS |
Conclusion
So this then is a rather quick walk through of extending Puppet in ways many of us would not have seen before. I spent about a day getting this all working which included figuring out a way to maintain the mutating report state internally etc, the outcome is a test suite I can run and it will thoroughly drive a working 5 node network and assert the outcomes against real machines running real software.
I used to have a MCollective integration test suite, but I think this is a LOT nicer mainly due to the Choria Playbooks and extensibility of modern Puppet.
$ mco playbook run cspec::suite --data `pwd`/suite.yaml --report `pwd`/report.json |
The current code for this is on GitHub along with some Terraform code to stand up a test environment, it’s a bit barren right now but I’ll add details in the next few weeks.