Create a custom function

Now that we have a custom type and provider to manage OpenLDAP databases, we will add two properties to manage rootdn and rootpw to declare the admin user of the database. Normally, you should use the slappasswd tool to hash a password (by default, it used the SSHA algorithm, but we don't want to depend on this tool installed on the Puppet master, so we'll hash the clear password using a pure Ruby approach).

Write the acceptance test

First, we'll write the acceptance test. We want to be able to connect to the database using admin credentials. Let's add a new context in spec/acceptance/openldap_database_spec.rb:

  context 'when setting rootdn and rootpw' do
    it 'should work with no errors' do
      pp = <<-EOS
        class { 'openldap::client': } ->
        class { 'openldap::server': } ->
        openldap_database { 'dc=bar,dc=com':
          ensure    => present,
          directory => '/tmp',
          rootdn    => 'cn=admin,dc=bar,dc=com',
          rootpw    => openldap_password('secret'),
        }
      EOS

      # Run it twice and test for idempotency
      apply_manifest(pp, :catch_failures => true)
      apply_manifest(pp, :catch_changes => true)
    end

    it 'can connect with ldapsearch' do
      ldapsearch('-LLL -x -b "dc=foo,dc=com" -D "cn=admin,dc=bar,dc=com" -w secret') do |r|
        expect(r.stdout).to match(/dn: dc=foo,dc=com/)
      end
    end
  end

Write the unit test

Now let's write the unit test. Unit tests for custom functions live in spec/unit/puppet/parser/functions, so let's create this directory first:

$ mkdir -p spec/unit/puppet/parser/functions

The first thing we'll do, as usual, is require our spec_helper:

require 'spec_helper'

describe Puppet::Parser::Functions.function(:openldap_password) do
  # Unit tests go here
end

We'll then need to initialize the scope variable needed to call function on it.

require 'spec_helper'

describe Puppet::Parser::Functions.function(:openldap_password) do
  let(:scope) { PuppetlabsSpec::PuppetInternals.scope }
end

Now that we have a scope, we can loop over all supported operating systems using rspec-puppet-facts and stub every fact in the top scope:

require 'spec_helper'

describe Puppet::Parser::Functions.function(:openldap_password) do
  let(:scope) { PuppetlabsSpec::PuppetInternals.scope }

  on_supported_os.each do |os, facts|
    context "on #{os}" do
      before :each do
        facts.each do |k, v|
          scope.stubs(:lookupvar).with("::#{k}").returns(v)
          scope.stubs(:lookupvar).with(k).returns(v)
        end
      end
      # Unit tests go here
    end
  end
end

Everything is now set up, so let's start writing some tests.

Check that the function exists

Let's first validate that the function is available:

      it 'should exist' do
        expect(
          Puppet::Parser::Functions.function('openldap_password')
        ).to eq('function_openldap_password')
      end

Let's try the unit tests:

$ bundle exec rake spec SPEC=spec/unit/puppet/parser/functions/openldap_password_spec.rb SPEC_OPTS=-fd
...
false
  on debian-7-x86_64
    should exist (FAILED - 1)
  on redhat-7-x86_64
    should exist (FAILED - 2)

Failures:

  1) false on debian-7-x86_64 should exist
     Failure/Error: expect(

       expected: "function_openldap_password"
            got: false

       (compared using ==)
     # ./spec/unit/puppet/parser/functions/openldap_password_spec.rb:16:in `block (4 levels) in '

  2) false on redhat-7-x86_64 should exist
     Failure/Error: expect(

       expected: "function_openldap_password"
            got: false

       (compared using ==)
     # ./spec/unit/puppet/parser/functions/openldap_password_spec.rb:16:in `block (4 levels) in '

Finished in 0.06486 seconds (files took 0.72167 seconds to load)
2 examples, 2 failures

Failed examples:

rspec ./spec/unit/puppet/parser/functions/openldap_password_spec.rb:15 # false on debian-7-x86_64 should exist
rspec ./spec/unit/puppet/parser/functions/openldap_password_spec.rb:15 # false on redhat-7-x86_64 should exist

It obviously fails because we don't have the openldap_password function code yet. Let's start writing it.

Write the function

Now that we have a skeleton for our unit tests, let's start writing the function. Puppet custom functions live in lib/puppet/parser/functions, so let's create this directory:

$ mkdir -p lib/puppet/parser/functions

Now let's write the function:

module Puppet::Parser::Functions
  newfunction(:openldap_password, :type => :rvalue, :doc => <<-EOS
    Returns the openldap password hash from the clear text password.
  EOS
  ) do |args|
    # Function code goes here
  end
end

If we launch the unit tests again, it should work because the function now does exist:

$ bundle exec rake spec SPEC=spec/unit/puppet/parser/functions/openldap_password_spec.rb SPEC_OPTS=-fd
...
function_openldap_password
  on debian-7-x86_64
    should exist
  on redhat-7-x86_64
    should exist

Finished in 0.06929 seconds (files took 0.77882 seconds to load)
2 examples, 0 failures

Write more tests

We want to make sure that our function takes one and only one argument (the plain text password). So let's check that it fails if we pass zero or two arguments:

      context 'when passing no arguments' do
        it 'should fail' do
          expect {
            scope.function_openldap_password([])
          }.to raise_error Puppet::ParseError, /Wrong number of arguments given/
        end
      end

      context 'when passing two arguments' do
        it 'should fail' do
          expect {
            scope.function_openldap_password(['foo', 'bar'])
          }.to raise_error Puppet::ParseError, /Wrong number of arguments given/
        end
      end

And the actual code in the function:

module Puppet::Parser::Functions
  newfunction(:openldap_password, :type => :rvalue, :doc => <<-EOS
    Returns the openldap password hash from the clear text password.
  EOS
  ) do |args|

    raise(Puppet::ParseError, "openldap_password(): Wrong number of arguments given") if args.size < 1 or args.size > 1
  end
end

Let's test:

$ bundle exec rake spec SPEC=spec/unit/puppet/parser/functions/openldap_password_spec.rb SPEC_OPTS=-fd
...
function_openldap_password
  on debian-7-x86_64
    should exist
    when giving no arguments
      should fail
    when giving two arguments
      should fail
  on redhat-7-x86_64
    should exist
    when giving no arguments
      should fail
    when giving two arguments
      should fail

Finished in 0.14474 seconds (files took 0.707 seconds to load)
6 examples, 0 failures

It works!

Write the unit test that verifies the hash

Ultimately, we want our function to return the SSHA of the password with the 4 first characters of the SHA1 of the fqdn as salt.

Let's calculate the expected result in irb (that's what slappasswd does), the fqdn foo.example.com comes from rspec-puppet-facts:

$ irb
irb(main):001:0> require 'base64'
=> true
irb(main):002:0> require 'digest'
=> true
irb(main):003:0> password='secret'
=> "secret"
irb(main):004:0> salt=Digest::SHA1.digest('foo.example.com')[0..4]
=> "\xC4\x1D\xBC,\xE6"
irb(main):005:0> "{SSHA}" + Base64.encode64("#{Digest::SHA1.digest("#{password}#{salt}")}#{salt}").chomp
=> "{SSHA}jZdUkbyDYvmpSKg0x/k879g+RY7EHbws5g=="

So we code the unit test this way:

      context 'when giving a secret' do
        it 'should return the SSHA of the password with the first 4 characters of sha1("foo.example.com") as salt' do
          expect(
            scope.function_openldap_password(['secret'])
          ).to eq('{SSHA}jZdUkbyDYvmpSKg0x/k879g+RY7EHbws5g==')
        end
      end

Write the function code

module Puppet::Parser::Functions
  newfunction(:openldap_password, :type => :rvalue, :doc => <<-EOS
    Returns the openldap password hash from the clear text password.
  EOS                                                  
  ) do |args|

    raise(Puppet::ParseError, "openldap_password(): Wrong number of arguments given") if args.size < 1 or args.size > 1

    password = args[0]      
    salt = Digest::SHA1.digest(lookupvar('::fqdn'))[0..4]  

    "{SSHA}" + Base64.encode64("#{Digest::SHA1.digest("#{password}#{salt}")}#{salt}").chomp
  end
end

This is not very secure because every hash on one node is generated using the same salt, but it is just an example.

Launch the unit test

$ bundle exec rake spec SPEC=spec/unit/puppet/parser/functions/openldap_password_spec.rb SPEC_OPTS=-fd
...
function_openldap_password
  on debian-7-x86_64
    should exist
    when giving no arguments
      should fail
    when giving two arguments
      should fail
    when giving a secret
      should return the SSHA of the password with the first 4 characters of sha1("foo.example.com") as salt
  on redhat-7-x86_64
    should exist
    when giving no arguments
      should fail
    when giving two arguments
      should fail
    when giving a secret
      should return the SSHA of the password with the first 4 characters of sha1("foo.example.com") as salt

Finished in 0.12028 seconds (files took 0.45853 seconds to load)
8 examples, 0 failures

It works!

Launch the acceptance test

On RedHat

$ BEAKER_set=centos-7-x86_64-vagrant bundle exec rspec spec/acceptance/openldap_database_spec.rb 
...
openldap_database
  when not setting rootdn and rootpw
localhost $ scp /tmp/beaker20150407-4273-1a19l9o centos-7-x64:/tmp/apply_manifest.pp.F6GY0s {:ignore => }
localhost $ scp /tmp/beaker20150407-4273-brpequ centos-7-x64:/tmp/apply_manifest.pp.nEHJi7 {:ignore => }
    should work with no errors
    can connect with ldapsearch
  when setting rootdn and rootpw
localhost $ scp /tmp/beaker20150407-4273-cw80mz centos-7-x64:/tmp/apply_manifest.pp.5xYinE {:ignore => }
localhost $ scp /tmp/beaker20150407-4273-rlhd7a centos-7-x64:/tmp/apply_manifest.pp.YKZNQ2 {:ignore => }
    should work with no errors
    can connect with ldapsearch
Destroying vagrant boxes
==> centos-7-x64: Forcing shutdown of VM...
==> centos-7-x64: Destroying VM and associated drives...

Finished in 33.09 seconds (files took 1 minute 53.74 seconds to load)
4 examples, 0 failures

It works! We can create a database with a rootdn and a rootpw and connect to it using the credentials.

On Debian

Now let's test on Debian:

$ BEAKER_set=debian-7-x86_64-vagrant bundle exec rspec spec/acceptance/openldap_database_spec.rb 
...
openldap_database
  when not setting rootdn and rootpw
localhost $ scp /tmp/beaker20150407-6104-1xk90v8 debian-7-x64:/tmp/apply_manifest.pp.SRNaJW {:ignore => }
localhost $ scp /tmp/beaker20150407-6104-9ni8y1 debian-7-x64:/tmp/apply_manifest.pp.FwTqA4 {:ignore => }
    should work with no errors
    can connect with ldapsearch
  when setting rootdn and rootpw
localhost $ scp /tmp/beaker20150407-6104-7tk1rl debian-7-x64:/tmp/apply_manifest.pp.azrQpS {:ignore => }
localhost $ scp /tmp/beaker20150407-6104-k6hhs0 debian-7-x64:/tmp/apply_manifest.pp.F9u9f8 {:ignore => }
    should work with no errors
    can connect with ldapsearch
Destroying vagrant boxes
==> debian-7-x64: Forcing shutdown of VM...
==> debian-7-x64: Destroying VM and associated drives...

Finished in 36.59 seconds (files took 1 minute 31.02 seconds to load)
4 examples, 0 failures

Everything is OK.