Code Sharing in Platform Specific 1-1 Custom Resources

3 minute read

One of the most complicated uses of HWRP-style resources in Chef is to do platform-specific providers. The old model of doing this looks something like the service resource and providers, with a wrapping single resource with many providers behind it.

This 1:N model of resources and providers does not work with Custom Resources by design (one of the two limitations of using custom resources). The model that is preferred, though, is the 1:1 model used by the package resources where when users type package in the DSL they will get different resources and providers based on the platform they are running on. On Ubuntu/Debian systems package is wired up to the apt_package resource and provider, while on RHEL-based systems package is wired up to the yum_package resource and provider.

In core chef (at the time this is written) all the package resources and provider share a base class which they inherit from. The other limitation of custom resources is that you cannot use inheritance (the only limitation of the custom resource DSL is that you do not create the class, can not control the class name, and can not control what base class it inherits from). That means that authors must use modules for code-sharing.

A full example of this model with custom resources looks like this:

recipes/default.rb:

doit "whatever" do
  mynamearg "whatever2"
end

cat resources/doit_debian.rb:

provides :doit_debian
resource_name :doit_debian
provides :doit, platform_family: "debian"

include ResourceDoitBase

action :run do
  file "/tmp/doit.debian" do
    content "debian"
  end
  action_helper
end

cat resources/doit_debian.rb:

provides :doit_debian
resource_name :doit_debian
provides :doit, platform_family: "debian"

include ResourceDoitBase

action :run do
  file "/tmp/doit.debian" do
    content "debian"
  end
  action_helper
end

cat libraries/resource_doit_base.rb:

module ResourceDoitBase
  def self.included(base)
    base.class_eval do

      #
      # Start of the shared code
      #

      property :mynamearg, String, name_property: true

      action_class do
        def action_helper
          puts "HELPED!"
        end
      end

      #
      # End of the shared code
      #

    end
  end
end

spec/unit/recipes/default_spec.rb:

require 'spec_helper'

describe 'platform_resources::default' do
  context 'When all attributes are default, on an Ubuntu 16.04' do
    let(:chef_run) do
      runner = ChefSpec::ServerRunner.new(platform: 'ubuntu', version: '16.04', step_into: ['doit'])
      runner.converge(described_recipe)
    end

    it 'converges successfully' do
      expect { chef_run }.to_not raise_error
    end

    it 'creates the /tmp/doit.debian file with the right contents' do
      expect(chef_run).to render_file('/tmp/doit.debian').with_content('debian')
    end
  end

  context 'When all attributes are default, on an CentOS 7.3.1611' do
    let(:chef_run) do
      # for a complete list of available platforms and versions see:
      # https://github.com/customink/fauxhai/blob/master/PLATFORMS.md
      runner = ChefSpec::ServerRunner.new(platform: 'centos', version: '7.3.1611', step_into: ['doit'])
      runner.converge(described_recipe)
    end

    it 'converges successfully' do
      expect { chef_run }.to_not raise_error
    end

    it 'creates the /tmp/doit.rhel file with the right contents' do
      expect(chef_run).to render_file('/tmp/doit.rhel').with_content('rhel')
    end
  end
end

The provides lines are responsible for correctly wiring up the platform-specific resources. Each one wires up a name for the custom resource which is unversal on any platform doit_debian and doit_rhel. The resource_name is set to the universal name so that in chef-client output it can be easily determined which resource actually ran (similar to how in chef >= 12.0 package "foo" will show as yum_package[foo] on RHEL and apt_package[foo] on Ubuntu). Then there is the platform-specific provide mapping for doit. On platforms other than debian or RHEL platforms typing doit will fail (the doit_rhel provider might need to consider the other platform families of fedora, amazon and/or suse).

The chefspec tests ensure that the platform-specific behavior is run correctly on different platforms.

The shared code is in the library file. If you try to insert property lines directly into a module then it will fail because at the time the module is being parsed the module does not know anything about properties or the action class. Using the ruby included hook with a class_eval lazies all the statements contained in the class_eval to when the module is included into another class. The statements inside the class_eval are run in the class just like if they were being typed into the resources file.

The action_class helper shows how to share code across actions across different custom resources. It is maximally useless in this example since it just prints a string.

The syntax in the library file is raw ruby and perhaps we could make this more elegant at some point in the future (but since this just ruby we’re never going to break it and you can go ahead and use it).