14 min read

In this article by Matthias Marschall, author of the book, Chef Cookbook, Third Edition, we will cover the following section:

  • Using community Chef style
  • Using attributes to dynamically configure recipes
  • Using templates
  • Mixing plain Ruby with Chef DSL

(For more resources related to this topic, see here.)

Introduction

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.

Using community Chef style

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.

Getting ready

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.

How to do it…

Let’s walk through a few Chef style guide examples:

  1. Use two spaces per indentation level:
    remote_directory node['nagios']['plugin_dir'] do
    
    source 'plugins'
    
    end
  2. Use Unix-style line endings. Avoid Windows line endings by configuring Git accordingly:
    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.

  3. Align parameters spanning more than one line:
    variables(
    
    mon_host: 'monitoring.example.com',
    
    nrpe_directory: "#{node['nagios']['nrpe']['conf_dir']}/nrpe.d"
    
    )
  4. Describe your cookbook in metadata.rb (you should always use the Ruby DSL:
  5. Version your cookbook according to Semantic Versioning standards (http://semver.org):
    version           "1.1.0"
  6. List the supported operating systems by looping through an array using the each method:
    %w(redhat centos ubuntu debian).each do |os|
    
    supports os
    
    end
  7. Declare dependencies and pin their versions in metadata.rb:
    depends "apache2", ">= 1.0.4"
    
    depends "build-essential"
  8. Construct strings from variable values and static parts by using string expansion:
    my_string = "This resource changed #{counter} files"
  9. Download temporary files to Chef::Config[file_cache_path] instead of /tmp or some local directory.
  10. Use strings to access node attributes instead of Ruby symbols:
    node['nagios']['users_databag_group']
  11. Set attributes in my_cookbook/attributes/default.rb by using default:
    default['my_cookbook']['version']     = "3.0.11"
  12. Create an attribute namespace by using your cookbook name as the first level in my_cookbook/attributes/default.rb:
    default['my_cookbook']['version']     = "3.0.11"
    
    default['my_cookbook']['name']       = "Mine"

How it works…

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.

There’s more…

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.

Using attributes to dynamically configure recipes

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.

Getting ready

Make sure you have a cookbook called my_cookbook and the run_list of your node includes my_cookbook.

How to do it…

Let’s see how to define and use a simple attribute:

  1. Create a default file for your cookbook attributes:
    mma@laptop:~/chef-repo $ subl cookbooks/my_cookbook/attributes/default.rb
  2. Add a default attribute:
    default['my_cookbook']['message'] = 'hello world!'
  3. Use the attribute inside a recipe:
    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}")
  4. Upload the modified cookbook to the Chef server:
    mma@laptop:~/chef-repo $ knife cookbook upload my_cookbook
    
        Uploading my_cookbook   [0.1.0]
  5. Run chef-client on your node:
    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.

There’s more…

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):

  1. Create a role:
    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!" }
  2. Upload the role to the Chef server:
    mma@laptop:~/chef-repo $ knife role from file german_hosts.rb
    
        Updated Role german_hosts!
  3. Assign the role to a node called server:
    mma@laptop:~/chef-repo $ knife node run_list add server 'role[german_hosts]'
    
    server:
    
         run_list: role[german_hosts]
  4. Run the Chef client on your node:
    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...

Calculating values in the attribute files

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:

  1. Set an attribute within a role:
    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"
    
        }
  2. Calculate the message attribute, based on the two attributes hi and world:
    mma@laptop:~/chef-repo $ subl cookbooks/my_cookbook/attributes/default.rb
    
        default['my_cookbook']['message'] = "#{node['my_cookbook']['hi']} #{node['my_cookbook']['world']}!"
  3. Upload the modified cookbook to your Chef server and run the Chef client on your node to see that it works, as shown in the preceding example.

See also

  • Read more about attributes in Chef at https://docs.chef.io/attributes.html

Using templates

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.

Getting ready

Make sure you have a cookbook called my_cookbook and that the run_list of your node includes my_cookbook.

How to do it…

Let’s see how to create and use a template to dynamically generate a file on your node:

  1. Add a template to your recipe:
    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
  2. Add the ERB template file:
    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 %>
    
  3. Upload the modified cookbook to the Chef server:
    mma@laptop:~/chef-repo $ knife cookbook upload my_cookbook
    
        Uploading my_cookbook   [0.1.0]
  4. Run the Chef client on your node:
    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...
  5. Validate the content of the generated file:
    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!

How it works…

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.

There’s more…

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.

See also

  • Read more about templates at https://docs.chef.io/templates.html

Mixing plain Ruby with Chef DSL

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.

Getting ready

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 >

How to do it…

Let’s play around with some Ruby constructs in chef-shell to get a feel for what’s possible:

  1. Get all nodes from the Chef server by using search from the Chef DSL:
    chef > nodes = search(:node, "hostname:[* TO *]")
    
    => [#<Chef::Node:0x00000005010d38 @chef_server_rest=nil, @name="server",
    
    ...TRUNCATED OUTPUT...
  2. Sort your nodes by name by using plain Ruby:
    chef > nodes.sort! { |a, b| a.hostname <=> b.hostname }.collect { |n| n.hostname }
    
    => ["alice", "server"]
  3. Loop through the nodes, printing their operating systems:
    chef > nodes.each do |n|
    
    chef > puts n['os']
    
    chef ?> end
    
    linux
    
    windows
    
    => [node[server], node[alice]]
  4. Log only if there are no nodes:
    chef > Chef::Log.warn("No nodes found") if nodes.empty?
    
    => nil
  5. Install multiple Ruby gems by using an array, a loop, and string expansion to construct the gem names:
    chef > recipe_mode
    
    chef:recipe > %w{ec2 essentials}.each do |gem|
    
    chef:recipe > gem_package "knife-#{gem}"
    
    chef:recipe ?> end
    
    => ["ec2", "essentials"]

How it works…

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.

There’s more…

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.

See also

  • Find out more about how to use Ruby in recipes here: https://docs.chef.io/chef/dsl_recipe.html

There’s more…

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.

Resources for Article:


Further resources on this subject:


LEAVE A REPLY

Please enter your comment!
Please enter your name here