Managing Bundler and Rubygems with Ansible

September 19, 2020

Since 2017, I’ve been managing & maintaining a growing number of Ruby & Elixir applications for a client. This includes a fairly complete Ansible setup.

If you use Ansible, make sure to check out tools like ServerSpec and Vagrant, which provide massive help to iteratively implement stuff like what you are about to read!

A recurring task consists of installing and upgrading the ruby versions in use (e.g. MRI Ruby, JRuby), carefully following the “Ruby Maintenance policy”.

The rubies installation is done via RVM and the official RVM Ansible role.

Here is a simple example of installation:

# freeze to control the upgrade pace of RVM itself
rvm1_rvm_version: 1.29.10
rvm1_rvm_check_for_updates: false

rvm1_rubies:
  - 'ruby-2.5.8'
  - 'jruby-9.2.13.0'

rvm1_user: "{{ application_user }}"

# we install the libs ourselves via Ansible
rvm1_autolib_mode: read-fail

A real-life example will have more variants, based on applications and environments (e.g. maybe you’ll want “candidate rubies” available only on staging environments initially).

By default with this setup, though, you will not have the ability to specify the exact version of rubygems you want to install (and same goes for bundler; the role supports rvm1_bundler_install: true, which installs the latest).

As you may know already, tight version control over your dependencies, including rubygems and bundler, is important to get reproducible setups and deployments, and keep a healthy lifestyle!

So here is my current solution for this.

Installing a given Rubygems version

In your variables, add:

rubygems_version: 3.1.4

In a install_rubygems.yml file, add:

- name: Check rubygems version for {{ item }}
  command: "{{ rvm1_rvm }} {{ item }} do gem --version"
  become_user: '{{ rvm1_user }}'
  register: installed_rubygems_version
  changed_when: False
  check_mode: no

- name: Install rubygems {{ rubygems_version }} for {{ item }}
  command: "{{ rvm1_rvm }} {{ item }} do gem update --system {{ rubygems_version }}"
  when: rubygems_version != installed_rubygems_version.stdout
  become_user: '{{ rvm1_user }}'

(This algorithm assumes there is always a pre-installed version of rubygems, which appears to be the case on the setups I manage).

You can then invoke it for each specified ruby:

- include: install_rubygems.yml
  with_items: "{{ rvm1_rubies }}"

Installing a given Bundler version

In the case of Bundler, the task is a bit more involved, for two reasons:

  • Installing Ruby may not install Bundler (hence checking its version could lead to errors).
  • Multiple versions could be installed at once (unlike rubygems) so we must be more careful & verify active version.

In your variables, add:

bundler_version: 2.1.4

Then in install_bundler.yml:

# Detect currently active version, with support for
# "nothing installed", and make sure to raise if the error
# returned is not exactly matching "not installed"
- name: Check bundler version for {{ item }}
  command: "{{ rvm1_rvm }} {{ item }} do bundle --version"
  become_user: '{{ rvm1_user }}'
  register: bundler_version_command_output
  changed_when: False
  check_mode: no
  failed_when: >
    (bundler_version_command_output.rc != 0) and 
    ("exec: bundler: not found" not in bundler_version_command_output.stderr) and
    ("exec: bundle: not found" not in bundler_version_command_output.stderr)

# This could be stored as a variable, but I'm keeping it
# close to the related code and "DRY"
- name: Register bundler version regexp
  set_fact:
    # NOTE: this does not support release candidates, on purpose for now.
    bundler_version_output_regexp: '^Bundler version (\d+\.\d+\.\d+)$'

# Fail ASAP if the task assumptions are not verified.
- name: Assert that bundler version output is formatted as expected
  assert:
    that:
      - bundler_version_command_output.stdout is match(bundler_version_output_regexp)
  when: bundler_version_command_output.rc == 0

# Make sure to start from a blank slate between each ruby
- name: Reset variable
  set_fact:
    installed_bundler_version: null

# On successful output, we can extract the version safely,
# since the format has been verified above
- name: Extract current bundler version from command output
  set_fact:
    installed_bundler_version: "{{ bundler_version_command_output.stdout | regex_search(bundler_version_output_regexp, '\\1') | first }}"
  when: bundler_version_command_output.rc == 0

# Proceed to installation
- name: Install bundler version {{ bundler_version }} for {{ item }}
  command: "{{ rvm1_rvm }} {{ item }} do gem install bundler -v={{ bundler_version }}"
  when: (installed_bundler_version is none) or (bundler_version != installed_bundler_version)
  become_user: '{{ rvm1_user }}'
  register: bundle_install_result

# As a double check, verify active version, since a more
# recent one could have been installed before.
# This will _not_ handle downgrades!
- name: Capture currently active bundler version for {{ item }}
  command: "{{ rvm1_rvm }} {{ item }} do bundle --version"
  become_user: '{{ rvm1_user }}'
  register: verification_bundler_version_command_output
  changed_when: False
  check_mode: no
  when: bundle_install_result.changed

- name: Verify currently active bundler version for {{ item }}
  assert:
    that:
      - verification_bundler_version_command_output.stdout == "Bundler version {{ bundler_version }}"
  when: bundle_install_result.changed

A tradeoff of this design is that downgrades cannot be achieved, you will have to create separate tasks for that, but otherwise it works very nicely.

Final words

As mentioned earlier in this post, if you have to implement similar stuff, make sure to check out ServerSpec and Vagrant, both very useful tools to help you create and improve your Ansible tasks and roles!