In this article by Matthias Marschall, author of the book, Chef Cookbook, Third Edition, we will cover the following section:
(For more resources related to this topic, see here.)
If you want to automate your infrastructure, you will end up using most of Chef's language features. In this article, we will look at how to use the Chef Domain Specific Language (DSL) from basic to advanced level.
It's easier to read code that adheres to a coding style guide. It is important to deliver consistently styled code, especially when sharing cookbooks with the Chef community. In this article, you'll find some of the most important rules (out of many more—enough to fill a short book on their own) to apply to your own cookbooks.
As you're writing cookbooks in Ruby, it's a good idea to follow general Ruby principles for readable (and therefore maintainable) code.
Chef Software, Inc. proposes Ian Macdonald's Ruby Style Guide (http://www.caliban.org/ruby/rubyguide.shtml#style), but to be honest, I prefer Bozhidar Batsov's Ruby Style Guide (https://github.com/bbatsov/ruby-style-guide) due to its clarity.
Let's look at the most important rules for Ruby in general and for cookbooks specifically.
Let's walk through a few Chef style guide examples:
remote_directory node['nagios']['plugin_dir'] do
source 'plugins'
end
mma@laptop:~/chef-repo $ git config --global core.autocrlf true
For more options on how to deal with line endings in Git, go to https://help.github.com/articles/dealing-with-line-endings.
variables(
mon_host: 'monitoring.example.com',
nrpe_directory: "#{node['nagios']['nrpe']['conf_dir']}/nrpe.d"
)
version "1.1.0"
%w(redhat centos ubuntu debian).each do |os|
supports os
end
depends "apache2", ">= 1.0.4"
depends "build-essential"
my_string = "This resource changed #{counter} files"
node['nagios']['users_databag_group']
default['my_cookbook']['version'] = "3.0.11"
default['my_cookbook']['version'] = "3.0.11"
default['my_cookbook']['name'] = "Mine"
Using community Chef style helps to increase the readability of your cookbooks. Your cookbooks will be read much more often than changed. Because of this, it usually pays off to put a little extra effort into following a strict style guide when writing cookbooks.
Using Semantic Versioning (see http://semver.org) for your cookbooks helps to manage dependencies. If you change anything that might break cookbooks, depending on your cookbook, you need to consider this as a backwards incompatible API change. In such cases, Semantic Versioning demands that you increase the major number of your cookbook, for example from 1.1.3 to 2.0.0, resetting minor level and patch levels.
Imagine some cookbook author has hardcoded the path where the cookbook puts a configuration file, but in a place that does not comply with your rules. Now, you're in trouble! You can either patch the cookbook or rewrite it from scratch. Both options leave you with a headache and lots of work.
Attributes are there to avoid such headaches. Instead of hardcoding values inside cookbooks, attributes enable authors to make their cookbooks configurable. By overriding default values set in cookbooks, users can inject their own values. Suddenly, it's next to trivial to obey your own rules.
In this section, we'll see how to use attributes in your cookbooks.
Make sure you have a cookbook called my_cookbook and the run_list of your node includes my_cookbook.
Let's see how to define and use a simple attribute:
mma@laptop:~/chef-repo $ subl cookbooks/my_cookbook/attributes/default.rb
default['my_cookbook']['message'] = 'hello world!'
mma@laptop:~/chef-repo $ subl cookbooks/my_cookbook/recipes/default.rb
message = node['my_cookbook']['message']
Chef::Log.info("** Saying what I was told to say: #{message}")
mma@laptop:~/chef-repo $ knife cookbook upload my_cookbook
Uploading my_cookbook [0.1.0]
user@server:~$ sudo chef-client
...TRUNCATED OUTPUT...
[2016-11-23T19:29:03+00:00] INFO: ** Saying what I was told to say: hello world!
...TRUNCATED OUTPUT...
How it works…
Chef loads all attributes from the attribute files before it executes the recipes. The attributes are stored with the node object. You can access all attributes stored with the node object from within your recipes and retrieve their current values.
Chef has a strict order of precedence for attributes: Default is the lowest, then normal (which is aliased with set), and then override. Additionally, attribute levels set in recipes have precedence over the same level set in an attribute file. Also, attributes defined in roles and environments have the highest precedence.
You will find an overview chart at https://docs.chef.io/attributes.html#attribute-precedence.
You can set and override attributes within roles and environments. Attributes defined in roles or environments have the highest precedence (on their respective levels: default and override):
mma@laptop:~/chef-repo $ subl roles/german_hosts.rb
name "german_hosts"
description "This Role contains hosts, which should print out their messages in German"
run_list "recipe[my_cookbook]"
default_attributes "my_cookbook" => { "message" => "Hallo Welt!" }
mma@laptop:~/chef-repo $ knife role from file german_hosts.rb
Updated Role german_hosts!
mma@laptop:~/chef-repo $ knife node run_list add server 'role[german_hosts]'
server:
run_list: role[german_hosts]
user@server:~$ sudo chef-client
...TRUNCATED OUTPUT...
[2016-11-23T19:40:56+00:00] INFO: ** Saying what I was told to say: Hallo Welt!
...TRUNCATED OUTPUT...
Attributes set in roles and environments (as shown earlier) have the highest precedence and they're already available when the attribute files are loaded. This enables you to calculate attribute values based on role or environment-specific values:
mma@laptop:~/chef-repo $ subl roles/german_hosts.rb
name "german_hosts"
description "This Role contains hosts, which should print out their messages in German"
run_list "recipe[my_cookbook]"
default_attributes "my_cookbook" => {
"hi" => "Hallo",
"world" => "Welt"
}
mma@laptop:~/chef-repo $ subl cookbooks/my_cookbook/attributes/default.rb
default['my_cookbook']['message'] = "#{node['my_cookbook']['hi']} #{node['my_cookbook']['world']}!"
Configuration Management is all about configuring your hosts well. Usually, configuration is carried out by using configuration files. Chef's template resource allows you to recreate these configuration files with dynamic values that are driven by the attributes we've discussed so far in this article. You can retrieve dynamic values from data bags, attributes, or even calculate them on the fly before passing them into a template.
Make sure you have a cookbook called my_cookbook and that the run_list of your node includes my_cookbook.
Let's see how to create and use a template to dynamically generate a file on your node:
mma@laptop:~/chef-repo $ subl cookbooks/my_cookbook/recipes/default.rb
template '/tmp/message' do
source 'message.erb'
variables(
hi: 'Hallo',
world: 'Welt',
from: node['fqdn']
)
end
mma@laptop:~/chef-repo $ mkdir -p cookbooks/my_cookbook/templates
mma@laptop:~/chef-repo $ subl cookbooks/my_cookbook/templates/default/message.erb
<%- 4.times do %>
<%= @hi %>, <%= @world %> from <%= @from %>!
<%- end %>
mma@laptop:~/chef-repo $ knife cookbook upload my_cookbook
Uploading my_cookbook [0.1.0]
user@server:~$ sudo chef-client
...TRUNCATED OUTPUT...
[2016-11-23T19:36:30+00:00] INFO: Processing template[/tmp/message] action create (my_cookbook::default line 9)
[2016-11-23T19:36:31+00:00] INFO: template[/tmp/message] updated content
...TRUNCATED OUTPUT...
user@server:~$ sudo cat /tmp/message
Hallo, Welt from vagrant.vm!
Hallo, Welt from vagrant.vm!
Hallo, Welt from vagrant.vm!
Hallo, Welt from vagrant.vm!
Chef uses Erubis as its template language. It allows you to use pure Ruby code by using special symbols inside your templates. These are commonly called the 'angry squid'
You use <%= %> if you want to print the value of a variable or Ruby expression into the generated file.
You use <%- %> if you want to embed Ruby logic into your template file. We used it to loop our expression four times.
When you use the template resource, Chef makes all the variables you pass available as instance variables when rendering the template. We used @hi, @world, and @from in our earlier example.
The node object is available in a template as well. Technically, you could access node attributes directly from within your template:
<%= node['fqdn'] %>
However, this is not a good idea because it will introduce hidden dependencies to your template. It is better to make dependencies explicit, for example, by declaring the fully qualified domain name (FQDN) of your node as a variable for the template resource inside your cookbook:
template '/tmp/fqdn' do
source 'fqdn.erb'
variables(
fqdn: node['fqdn']
)
end
Avoid using the node object directly inside your templates because this introduces hidden dependencies to node variables in your templates.
If you need a different template for a specific host or platform, you can put those specific templates into various subdirectories of the templates directory. Chef will try to locate the correct template by searching these directories from the most specific (host) to the least specific (default).
You can place message.erb in the cookbooks/my_cookbook/templates/host-server.vm ("host-#{node[:fqdn]}") directory if it is specific to that host. If it is platform-specific, you can place it in cookbooks/my_cookbook/templates/ubuntu ("#{node[:platform]}"); and if it is specific to a certain platform version, you can place it in cookbooks/my_cookbook/templates/ubuntu-16.04 ("#{node[:platform]}-#{node[:platorm_version]}"). Only place it in the default directory if your template is the same for any host or platform.
Know the templates/default directory means that a template file is the same for all hosts and platforms—it does not correspond to a recipe name.
To create simple recipes, you only need to use resources provided by Chef such as template, remote_file, or service. However, as your recipes become more elaborate, you'll discover the need to do more advanced things such as conditionally executing parts of your recipe, looping, or even making complex calculations.
Instead of declaring the gem_package resource ten times, simply use different name attributes; it is so much easier to loop through an array of gem names creating the gem_package resources on the fly.
This is the power of mixing plain Ruby with Chef Domain Specific Language (DSL). We'll see a few tricks in the following sections.
Start a chef-shell on any of your nodes in Client mode to be able to access your Chef server, as shown in the following code:
user@server:~$ sudo chef-shell --client
loading configuration: /etc/chef/client.rb
Session type: client
...TRUNCATED OUTPUT...
run `help' for help, `exit' or ^D to quit.
Ohai2u user@server!
chef >
Let's play around with some Ruby constructs in chef-shell to get a feel for what's possible:
chef > nodes = search(:node, "hostname:[* TO *]")
=> [#<Chef::Node:0x00000005010d38 @chef_server_rest=nil, @name="server",
...TRUNCATED OUTPUT...
chef > nodes.sort! { |a, b| a.hostname <=> b.hostname }.collect { |n| n.hostname }
=> ["alice", "server"]
chef > nodes.each do |n|
chef > puts n['os']
chef ?> end
linux
windows
=> [node[server], node[alice]]
chef > Chef::Log.warn("No nodes found") if nodes.empty?
=> nil
chef > recipe_mode
chef:recipe > %w{ec2 essentials}.each do |gem|
chef:recipe > gem_package "knife-#{gem}"
chef:recipe ?> end
=> ["ec2", "essentials"]
Chef recipes are Ruby files, which get evaluated in the context of a Chef run. They can contain plain Ruby code, such as if statements and loops, as well as Chef DSL elements such as resources (remote_file, service, template, and so on).
Inside your recipes, you can declare Ruby variables and assign them any values. We used the Chef DSL method search to retrieve an array of Chef::Node instances and stored that array in the variable nodes.
Because nodes is a plain Ruby array, we can use all methods the array class provides such as sort! or empty? Also, we can iterate through the array by using the plain Ruby each iterator, as we did in the third example.
Another common thing is to use if, else, or case for conditional execution. In the fourth example, we used if to only write a warning to the log file if the nodes array are empty.
In the last example, we entered recipe mode and combined an array of strings (holding parts of gem names) and the each iterator with the Chef DSL gem_package resource to install two Ruby gems. To take things one step further, we used plain Ruby string expansion to construct the full gem names (knife-ec2 and knife-essentials) on the fly.
You can use the full power of Ruby in combination with the Chef DSL in your recipes. Here is an excerpt from the default recipe from the nagios cookbook, which shows what's possible:
# Sort by name to provide stable ordering
nodes.sort! { |a, b| a.name <=> b.name }
# maps nodes into nagios hostgroups
service_hosts = {}
search(:role, ‚*:*') do |r|
hostgroups << r.name
nodes.select { |n| n[‚roles'].include?(r.name) if n[‚roles'] }.each do |n|
service_hosts[r.name] = n[node[‚nagios'][‚host_name_attribute']]
end
end
First, they use Ruby to sort an array of nodes by their name attributes.
Then, they define a Ruby variable called service_hosts as an empty Hash. After this, you will see some more array methods in action such as select, include?, and each.
If you don't want to modify existing cookbooks, this is currently the only way to modify parts of recipes which are not meant to be configured via attributes.
This approach is exactly the same thing as monkey-patching any Ruby class by reopening it in your own source files. This usually leads to brittle code, as your code now depends on implementation details of another piece of code instead of depending on its public interface (in Chef recipes, the public interface is its attributes).
Keep such cookbook modifications in a separate place so that you can easily find out what you did later. If you bury your modifications deep inside your complicated cookbooks, you might experience issues later that are very hard to debug.
Further resources on this subject: