Wednesday, June 3, 2020

Using Chef Event Handlers for Resources

Chef Provides a comprehensive list of events and different ways to handle them. These are decently documented at https://docs.chef.io/handlers.
In a recent use case, I had to ensure that a folder has only the files I wanted it to have, any more files or directories should get deleted whenever the chef-client runs. Similarly, if a file is missing, it should be re-created. In addition, I must know the names of the files that were created/deleted and the name and the content of the file that was updated.

Problem 1 - How to find which files you don't want?
There's no easy way chef provides this, so a bit of Ruby code to the rescue.
# The path where all my source files are stored.
# For me the source is the cookbook itself.
source_path = "#{__dir__}/../files/default/myfiles"

# Path on Target; where I need all my files
# Set some temporary variables
target_files = []

# Deleted Files
deleted_files = []

# updated_files
updated_files = {}

# The code to populate our source files dynamically
Dir.chdir(source_path) do
  source_files = Dir.glob("*")
end

# Ensure Target Directory Exists
directory target_path
# List of Files on Target
begin
  Dir.chdir(target_path) do
    target_files = Dir.glob("*")
  end
rescue
  Chef::Log.info "Directory may not exists, skipping as non-fatal"
end

files_to_delete = target_files - source_files
Chef::Log.info "Total Source Files: #{source_files.length}"
Chef::Log.info "Total Target Files: #{target_files.length}"
Chef::Log.info "Files to delete: #{files_to_delete}"
Problem 2 - How to find which files were created/deleted/modified?
This is where the Chef handler events kick in. I prefer using the 'on event' style.
# Add the Resource Handler
Chef.event_handler do
 on :resource_updated do |resource, action, update|
   begin
     if resource.is_a?(Chef::Resource::File) or resource.is_a?(Chef::Resource::CookbookFile) or resource.is_a?(Chef::Resource::Directory)
       if action == :delete
         deleted_files << resource.path
       elsif action == :create
         updated_files.store(resource.path, resource.diff)
       end
     end
   rescue
     # do nothing
   end
  end
end
The code above will get triggered everytime a resource is updated (any resource). Chef provides different events which you can trap, here I was interested to know only about the updates. I was also only interested in if the resource was a File or a Directory.

Bringing the last bits together.
Once I knew which files to delete, I could just delete them and the event above would keep a track of which files were deleted. Similarly for file creation and modification.
files_to_delete.each do | f |
  if File.directory?("#{target_path}/#{f}")
    directory "#{target_path}/#{f}" do
      recursive true
      action :delete
    end
  else
    file "#{target_path}/#{f}" do
      action :delete
    end
  end
end
source_files.each do | f |
  cookbook_file "#{target_path}/#{f}" do
    source "#{f}"
  end
end

# Finally, log a message with all files deleted.
# Notice the use of lazy block to evaluate the variable at converge time.
if !deleted_files.empty?
  log 'deleted_files' do
    message lazy { "Deleted Files: #{deleted_files}" }
  end
end

if !updated_files.empty?
  log 'updated_files' do
    message lazy { "Updated Files: #{updated_files}" }
  end
end

ruby_block 'updated_status' do
  block do
    node.normal['deleted_files'] =  deleted_files
    node.normal['updated_files'] =  updated_files
    node.normal['last_run_time'] =  Time.now
  end
end