donatas abraitis

Incremental deployment with Ansible

Previous month I wrote how we reduced converge time to 1h for the whole infrastructure. Today I can say, it’s way too much to wait for changes. We wanted to see changes faster than waiting an hour or sometimes unpredictable amount of time.

Day by day run list is getting fatter, thus the challenge was to keep this task as simple as possible and do not introduce any complex layer underneath. We made a resolution (raising eyebrow) to involve simple versioning mechanism for roles.

The idea is very simple:

  • Keep current versions in Redis;
  • Iterate over roles and compare current role’s version with the new one;
  • If they differ, use this role for this build.

This improved build just deploys roles their versions were increased. To have fully consistent state between all nodes we trigger another Jenkins’s build like ansible-hostinger-full to reflect all changes across all nodes equally. But with this incremental deployment, we can see changes rapidly.

Here is an example how to manipulate dynamic playbook file:

require 'redis'
require 'yaml'

# ansible_deploy.rb must be deployed to `ansible-repo` root directory, like:
# ansible-repo/
#   roles/
#   host_vars/
#   group_vars/
#   ansible_deploy.rb
class AnsibleDeploy
  def initialize(source, destination)
    @source = source
    @destination = destination
    @run_list = {}
    @current_roles = {}
    @redis = Redis.new
  end

  def run_list
    Dir['roles/*/version'].each do |file|
      name = role_name(file)
      version = role_version(file)
      @current_roles[name] = get_role_version(name)
      update_role_version(name, version)
    end
    render_playbook
  end

  private

  def role_name(file)
    file.match(%r{roles\/(.*)\/version})[1]
  end

  def role_version(file)
    File.read(file).chop
  end

  def get_role_version(role)
    @redis.hmget("ansible:#{@source}:role:#{role}", 'version').first
  end

  def update_role_version(role, version)
    @redis.hmset("ansible:#{@source}:role:#{role}", 'version', version)
    @run_list[role] = version
  end

  def diff
    @run_list.map do |role, version|
      next if @current_roles[role] == version
      role
    end.compact
  end

  def roles_from_yaml
    YAML.load_file(@source).last['roles']
  end

  def deploy_info_from_yaml
    YAML.load_file(@source).map do |playbook|
      playbook.delete('roles')
      playbook
    end
  end

  def render_playbook
    playbooks = deploy_info_from_yaml.map do |deploy|
      if mapping.any?
        deploy.merge('roles' => mapping)
      else
        deploy
      end
    end
    File.write(@destination, playbooks.to_yaml)
  end

  def mapping
    roles_from_yaml.map do |role|
      if role.is_a? String
        role if diff.include?(role)
      else
        role if diff.include?(role['role'])
      end
    end.compact
  end
end

source = ARGV[0]
destination = ARGV[1]

AnsibleDeploy.new(source, destination).run_list

Build looks like:

% ruby ./ansible_deploy.rb hostinger.yml hostinger-dynamic.yml
% ansible-playbook hostinger-dynamic.yml -i hosts

Everything is trivial, take hostinger.yml file as a source, strip roles part from this YAML and generate the new one hostinger-dynamic.yml with appropriate run_list.

Conclusion

  • You have inconsistent state if automation fails;
  • Make simple tasks simple;
  • Ansible sucks in either way :)