/ Chef

Coding a Chef cookbook for multiple platforms

If you use Chef in even a moderately complex environment, you are most likely going to create cookbooks for multiple operating systems. In this situation, it make sense to develop a coding pattern so that it is similar from cookbook to cookbook. The problem is, there will be small differences that will ratchet up the complexity of that code. This can't always be avoided, but there are some basic patterns that can make a logical flow, as opposed to a big bowl of spaghetti code.

I often use configuring the NTP service as an example in creating cookbooks. It's a fairly simple thing to set up, but the details can be different between operating systems and implementations. Another good reason is that it can be configured completely with the Chef primitives (package, template, service, etc.), and so custom resources don't have to be used (though you can for practice!).

So then, lets assume that we want to configure NTP on two different OS platforms: Red Hat Linux and IBM AIX. The differences here are going to be in the configuration file, service name, and method of enabling the service. How do we make allowances for these in code, while also making it readable for the next person that is stuck with updating it when the requirements change?

Some patterns

1. Make two different cookbooks; one for each platform
* Separates OS specific code
* When used for all similar situations, it will create a very large number of cookbooks that will all require individual updates when configuration changes that affect multiple platforms are required

2. Create one cookbook with the differences coded in-line with one default recipe
* Can make sense when the differences are minimal
* One place to make changes to multiple platforms
* When there are many differences, even small ones, can cause some spaghetti code issues pretty quickly

3. Create one cookbook with multiple platform specific recipes, and one default recipe to determine which platform specific recipe will be run
* Combines the best parts of the first and second patterns above: individual code for each OS and one cookbook to update for changes affecting multiple platforms
* Can still use the idea behind option two for a generic platform, where there could still be slight differences based on release: i.e. one redhat.erb recipe that will cover all Red Hat, but allow for differences that may be present in different releases
* Could get cumbersome if there are several specific OS recipes in use within the cookbook

Opinion

In my opinion options 2 and 3 are usually going to be the best when one Chef environment will be configuring multiple operating systems, with option 3 being my preferred overall.

As mentioned, option 3 will allow for the best of options 1 and 2. It will allow for logically separated code per platform. With that, you can allow for complexity of stuff like version differences in operating system setup without having to also worry about larger differences between completely different platforms. However, you can also keep the number of "places" that code has to be updated to one, which will simplify global config changes down the road.

Examples

Option 2a

This is the simplistic version of option 2. One recipe to accomodate two distinct platforms.

# Platform specific settings
case node['platform']
when 'redhat'
  # Red Hat settings (ntpd)
  service_name = 'ntpd'
  service_action = [:start, :enable]

when 'aix'
  # AIX settings
  service_name = 'xntpd'
  service_action = [:start]
end

# Install package if Red Hat
package 'ntp' do
  only_if { node['platform'] == 'redhat' }
end

# NTP configuration file: same path on both if using ntpd on Red Hat
template '/etc/ntp.conf' do
  owner 'root'
  group node['root_group'] # may ohai keep us forever sane
  mode '0644'
  source 'ntp.conf.erb' # rely on source being in platform directory of templates
  notifies :restart, "service[#{service_name}]"
end

# Setup service: different name and actions per platform here
# Red Hat is easy, we can just do :start, :enable
# AIX is a little more messy.  We can do :start, but enable is handled via inittab
service service_name do
  action service_action
end

# AIX ONLY: enable xntpd via inittab
execute 'add xntpd to inittab on aix' do
  command 'mkitab ntpd:2:wait:/usr/bin/startsrc -x xntpd > /dev/null 2>&1'
  not_if 'lsitab ntpd'
end

REPO LINK

If you are reasonably used to coding with Chef, this is fairly easy to read. In this situation, I would argue that this is perfectly reasonable code. There are some things built into Chef that help us in a few places, and using comments (even my sloppy version) is helpful to those coming after you. A couple of notes on this code:

--- the good --
* Variables make things easier. The ability to put differnces into variables that can be used in the resources later makes things much easier. Imagine having to have two resources for each shared thing, such as service. The complexity would be too much.

* Ohai saves you when it comes to one super annoying difference between some platforms, which is the group that the root user belongs too. This simplifies code some so that you code these kinds of differences without any extra logic.

* Chef does another great thing with it's template resource file specificity. It's nice to be able to just plop stuff down int template/platform/thing.erb and have Chef automagically find it for us. Meaning we don't have to do something like:

template '/the/file' do
  source 'aix/file.erb' if node['platform'] == 'aix'
  source 'redhat/file.erb' if node['platform'] == 'redhat'
end

It is just done for us.

--- the bad ---
* Not much too bad about this example. The logic is fairly simple. The bad aspects are just the requirements around configuring this service on different platforms, but that's not Chefs fault :)

Option 2b

A more complicated version of option 2: Same as the first example, but now there are differences that need to be taken care of within a single platform as well.

# Platform specific settings
case node['platform']
when 'redhat'
  # Red Hat settings differ by release now
  case node['platform_version'].split('.')[0]
  when '6'
    # RHEL 6 settings: ntpd
    pakcage_name = 'ntp'
    service_name = 'ntpd'
    config_source = 'ntp.conf.erb'
    config_path = '/etc/ntp.conf'
    service_action = [:start, :enable]
    
  when '7'
    # RHEL 7 settings: chrony
    package_name = 'chrony'
    service_name = 'chronyd'
    config_source = 'chrony.conf.erb'
    config_path = '/etc/chrony.conf'
    service_action = [:start, :enable]
  end
  
when 'aix'
  # AIX settings
  service_name = 'xntpd'
  config_source = 'ntpd.conf.erb'
  config_path = '/etc/ntp.conf'
  service_action = [:start]
end

# Install package if Red Hat
package package_name do
  only_if { node['platform'] == 'redhat' }
end

# NTP configuration file: same path on both if using ntpd on Red Hat
template config_path do
  owner 'root'
  group node['root_group'] # may ohai keep us forever sane
  mode '0644'
  source config_source # rely on source being in platform directory of templates
  notifies :restart, "service[#{service_name}]"
end

# Setup service: different name and actions per platform here
# Red Hat is easy, we can just do :start, :enable
# AIX is a little more messy.  We can do :start, but enable is handled via inittab
service service_name do
  action service_action
end

# AIX ONLY: enable xntpd via inittab
# We could certainly use a better custom provider here, as in the AIX community cookbook
#   but for the sake of simplicity:
execute 'add xntpd to inittab on aix' do
  command 'mkitab ntpd:2:wait:/usr/bin/startsrc -x xntpd > /dev/null 2>&1'
  not_if 'lsitab ntpd'
end

REPO LINK

Notice that there is now another layer of decision code in the beginning. Also, more of the specifics of the resources need to be variablized because of the differences between the Red Hat releases. Once you have to start making these kinds of distinctions in the code, the readability suffers. In truth, this example really isn't very bad. But much more coding around the differences in the the platforms and versions would start to make the code less readable. If you are looking at this code for the first time, you will have to correlate a lot of variables with the resources. Add to that the need to keep what is happening on one platform and not on another separated, and things are getting a little more complicated.

Even within this example, complexity has been left out. For instance, if we are switching to chrony as the implementation of NTP, we would probably want to add code to make sure ntpd is removed. Somewhere in the above recipe, we would add something like this (at a minimum):

# Remove ntpd if using chrony
if package_name == 'chrony'
  # Stop and disable ntpd
  service 'ntpd' do
    action [:stop, :disable]
  end
  
  # Remove ntpd configuration file
  file '/etc/ntp.conf' do
    action :delete
  end
  
  # Remove ntpd package
  package 'ntp' do
    action :remove
  end
end

Imagine a cookbook that configures something more complex, such as LDAP authentication. The mechanisms between platforms can be quite dissimilar. Or what if we start adding more platforms, or versions of platforms with more differences.

Even so, the good aspects are the same as the first example, and Chef keeps us sane by providing a great DSL to build our logic.

Option 3

To my way of thinking, this is the best option in a multi platform environment. In this example the platform distinctions are made in the default recipe. This allows for the specifics of the setup to be more obvious within the code that is actually doing the configuration work. There is still a difference made between the implementations of NTP for the Red Hat versions, but having each implementation within it's own cookbook simplifies the readability of the code.

Below are the four recipes:

  • default.rb - decides what recipe will be doing the NTP configuration on the node
  • redhat_ntpd.rb - will configure the standard NTP daemon (ntpd) on Red Hat nodes
  • redhat_chrony.rb - will configure the chrony NTP daemon on Red Hat nodes
  • aix.rb - Will configure the standard NTP daemon (xntpd) on AIX
## default.rb

case node['platform']
when 'redhat'
  # Find Red Hat release version
  pv = node['platform_version'].split('.')[0]

  # Call implementation recipe based on platform version
  include_recipe 'multi-platform-ntp-multi-recipe::redhat_ntpd' if pv == '6'
  include_recipe 'multi-platform-ntp-multi-recipe::redhat_chrony' if pv == '7'

when 'aix'
  # Call AIX platform recipe
  include_recipe 'multi-platform-ntp-multi-recipe::aix'

end
## redhat_ntpd.rb

# Confirm ntpd installed
package 'ntp'

# NTP configuration file
template '/etc/ntp.conf' do
  owner 'root'
  group node['root_group'] # may ohai keep us forever sane
  mode '0644'
  source 'ntp.conf.erb' # rely on source being in platform directory of templates
  notifies :restart, 'service[ntpd]'
end

# Service setup
service ntpd do
  action [:start, :enable]
end
## redhat_chrony.rb

# Install package
package 'chrony' do
  only_if { node['platform'] == 'redhat' }
end

# NTP configuration file
template '/etc/chrony.conf' do
  owner 'root'
  group node['root_group'] # may ohai keep us forever sane
  mode '0644'
  source 'chrony.conf.erb' # rely on source being in platform directory of templates
  notifies :restart, "service[#{service_name}]"
end

# Chrony service setup
service service_name do
  action [:start, :enable]
end
## aix.rb

# NTP configuration file
template config_path do
  owner 'root'
  group node['root_group']
  mode '0644'
  source ntp.conf.erb
  notifies :restart, "service[xntpd]"
end

# Ensure started at chef-client run time
service xntpd do
  action [:start]
end

# Enable xntpd via inittab
# We could certainly use a better custom provider here, as in the aix community cookbook
#   but for the sake of simplicity:
execute 'add xntpd to inittab on aix' do
  command 'mkitab ntpd:2:wait:/usr/bin/startsrc -x xntpd > /dev/null 2>&1'
  not_if 'lsitab ntpd'
end

REPO LINK

Conclusion

While the examples here are pretty minimal, hopefully they are sufficient to get the point across. The "right" way to do things will always rest in the requirements of your individual environments. No matter what pattern you chose, keep things simple, and keep things consistent.

Thanks for reading, and feel free to hit me up on twitter to discuss.