Writing Libraries in Chef Cookbooks
// Chef Blog
One of the most useful extensions available to Chef cookbook authors is the ability to write and use any arbitrary Ruby code as a library. These libraries are often no more than a few lines long, but can also be as simple or as sophisticated as you want. Once written, the methods in the library can be re-used by recipes within any cookbook that depends on it.
In this blog post, I'll give you a quick tour of the library functionality in Chef and also explain how to shell out safely to the operating system to execute arbitrary commands.
When Should I Write a Library?
The most common use case for a library is to provide helper methods to your cookbook, either to reduce code duplication, or simply to make recipe code more understandable by hiding implementation logic. Consider a trivial example: suppose I only want to have my user account created on a system if it has bacon. I might write a resource declaration like this:
user 'jdunn' do action :create only_if 'getent passwd bacon' end
This works perfectly well. However, more readable code that hides the implementation detail of 'getent' from the recipe might look something like this:
user 'jdunn' do action :create only_if { has_bacon? } end
In this way, I've clearly stated, in an English-like way, the conditions for the creation of my account and I've hidden my acceptance criteria behind a helper method. If I later change the conditions under which I'm satisfied that the system has bacon, I only have to modify the implementation logic of the helper, without touching recipe code. In computer science this is called "information hiding".
Writing the Library
Let's write some library code to implement the desired functionality. Create a file in the libraries directory of the cookbook. It can be named anything, but for a helper library it's common to name it #{cookbook_name}_helper.rb. My cookbook, which is going to be called "demo", will therefore have a file called demo_helper.rb.
Here's some code for the has_bacon? method. (In Ruby, method names can contain punctuation like ? and ! to make code more readable and English-like. By convention, methods with a '?' in their name return a boolean.)
module Demo module Helper include Chef::Mixin::ShellOut def has_bacon? cmd = shell_out!('getent passwd bacon', {:returns => [0,2]}) cmd.stderr.empty? && (cmd.stdout =~ /^bacon/) end end end
You can think of the module statements as creating a "namespace" for our helper library. It's a good idea to namespace your library code in this way, so that methods with the same name across multiple cookbooks do not collide.
Shelling Out Safely
Chef comes with a cross-platform library called mixlib-shellout to do subprocess creation safely. The library also knows how to deal with various malfunctioning Unix fork implementations, Ruby garbage collection bugs, reaping dead children, and all the fun housekeeping that comes along with subprocess management. We recommend using it, rather than system or Process.spawn, since badly-behaved subprocesses could hang or crash the entire Chef run.
Chef::Mixin::ShellOut is a wrapper around mixlib-shellout that handles loading the Gem and other housekeeping activities, so we load that with the include statement. This will also make a number of methods available to our library, chief among them being shell_out and shell_out!. The first method blindly executes commands ("fire and forget"), whereas the latter will raise an exception if errors occur. Also, we need to pass a hash of acceptable return values to shell_out!, otherwise getent will return "2″ on systems that don't have a bacon user and trigger an exception.
Finally, we can test the output from shell_out! to see if the system has bacon. As long as there were no errors printed to STDERR, and the output from getent contains "bacon" at the beginning of the output, then we consider the system to have bacon. In Ruby, we don't need to explicitly declare a return value; methods return the value of the last statement executed.
Using Libraries in Recipe Code
Great, so now we have the has_bacon? method written. There's one more thing we need to do in the recipe to wire up the library: we need to include it. The final recipe code will look something like this:
Chef::Resource::User.send(:include, Demo::Helper) user 'jdunn' do action :create only_if { has_bacon? } end
I've explained in a previous blog post why we use Chef::Resource::User.send(:include, Demo::Helper) here, rather than just include. (In this case, since we're using a guard that's going to be called in the context of the user resource, we need to mix it into that class, rather than Chef::Recipe).
Congratulations! We've written our first library. (Enjoy the bacon.)
Testing Your Libraries
Of course, you should test libraries as well. We're writing pure Ruby now, so we'll be using RSpec. The test code is too long to reproduce inline so here's a link to it.
The topic of testing libraries could probably merit a whole other blog post, so I won't delve into the details here. There are just a couple of things to point out about my test code:
- We'll need to mock a dummy class and include our helper, just like the recipe DSL would.
- Under the hood I know that shell_out! is going to instantiate an instance of Mixlib::ShellOut, so I need to stub that object's 'new' method.
- In my spec_helper.rb, I need to explicitly include all libraries in this cookbook, since RSpec by default is only going to load tests in the spec directory.
- I've also set up spec_helper to run any ChefSpec tests against resources in my cookbook proper, so I can run unit tests for everything just with one command.
I hang my head in shame before Martin Fowler if I screwed up the definitions of "mock" and "stub", but I think I got it right.
Concluding Remarks
Libraries are a great way to extend your Chef cookbook code and define your own custom helper methods, as well as to provide any additional functionality you want to write in Ruby. What we've written here using Ruby modules is what's known in computer science as a mixin. You can't instantiate mixins as objects, but you can mix them into Chef's classes to use the methods as though they were originally implemented as part of the recipe DSL (domain-specific language). However, you can also write libraries as classes and instantiate them as objects if you like.
You will sometimes see resources and providers written as full-blown Ruby classes in the libraries directory, rather than using the Lightweight Resources & Providers system. These will inherit from Chef::Resource and Chef::Provider respectively. Cookbook authors have a variety of reasons for not using LWRPs; for instance, sometimes they wish to provide features in their resources that are outside of the capabilities of the framework.
Finally, many community cookbooks have examples of libraries: they vary from simple helper methods like the ones in the Windows cookbook all the way to the complex provider logic in the Jenkins cookbook. Don't be afraid to steal someone else's code and experiment, and have fun!
Many thanks to my colleague Tom Duffield for reviewing my test code so that we could include it. I'm still learning RSpec!
----
Shared via my feedly reader
Sent from my iPhone
No comments:
Post a Comment