View on GitHub

emersonprado.github.io

Start page
Português

Efficient Vagrantfiles – Part 2 – Vagrant is Ruby!

Emerson Prado - 26/05/2019

In the first part of this series, I kept stating that Vagrant is Ruby. Let's see what's that for.

  1. TL;DR
  2. Problem - code repetition
  3. Solution - variables, loops and conditionals
    1. Arrays and Hashes
    2. The each loop
    3. Conditionals
  4. What does it have to do with the Vagrantfile?
  5. How far can we get?

TL;DR

If you already know Ruby basics, and can play with arrays, hashes, each loops and conditionals, you might skip this part of the articles, and get going with what I've summarized below.

You already have a working Vagrantfile, but it has a reasonable amount of repeated code. You can get rid of repetitions by:

Iterating an array to create similar virtual machines. Example:

  Vagrant.configure("2") do |config|
    [
      'vm_1',
      'vm_2',
      'vm_3'
    ].each do |name|
      config.vm.define name do |vm|
        <Settings>
      end
    end
  end
  

Iterating a hash to create virtual machines with one variyng attribute. Example:

  Vagrant.configure("2") do |config|
    {
      'vm_1' => 'ARTACK/debian-jessie',
      'vm_2' => 'ARTACK/debian-jessie',
      'vm_3' => 'centos/7'
    }.each do |name, box|
      config.vm.define name do |vm|
        vm.vm.box = box
      end
    end
  end
  

Iterating a hash of hashes to create virtual machines with multiple varying attributes. Example:

  Vagrant.configure("2") do |config|
    {
      'vm_1' => {
        :box => 'ARTACK/debian-jessie',
        :ip => '192.168.1.2'
      },
      'vm_2' => {
        :box => 'ARTACK/debian-jessie',
        :ip => '192.168.1.3'
      },
      'vm_3' => {
        :box => 'centos/7',
        :ip => '192.168.1.4'
      }
    }.each do |name, confs|
      config.vm.define name do |vm|
        vm.vm.box = confs[:box]
        vm.vm.network "private_network", ip: confs[:ip]
      end
    end
  end
  

Use conditionals to set attributes to a subset of virtual machines. Example (ip, in this case):

  Vagrant.configure("2") do |config|
    {
      'vm_1' => {
        :box => 'ARTACK/debian-jessie',
        :ip => '192.168.1.2'
      },
      'vm_2' => {
        :box => 'ARTACK/debian-jessie',
        :ip => '192.168.1.3'
      },
      'vm_3' => {
        :box => 'centos/7',
      }
    }.each do |name, confs|
      config.vm.define name do |vm|
        vm.vm.box = confs[:box]
        vm.vm.network "private_network", ip: confs[:ip] if confs.has_key?(:ip)
      end
    end
  end
  

Now you surely can think of several ways to use nested hashes and conditionals in a myriad of possible situations. Just keep in mind that the hashes should be readable by non-developers - Vagrant is made to make VMs management simpler, not rocket science. Now let's go to part 3 and start to get rid of the messy look of big hashes prepended to loops.

Problem - code repetition

Suppose you need a Vagrant environment with lots of virtual machines. Following the way of the first part - which I used when I began using Vagrant - you would need to repeat the same code for each VM. Example:

  Vagrant.configure("2") do |config|
    config.vm.define 'vm_1' do |vm|
      vm.vm.box = <Box>
      <Settings>
    end
    config.vm.define 'vm_2' do |vm|
      vm.vm.box = <Box>
      <Settings>
    end
    ...
    config.vm.define 'vm_N' do |vm|
      vm.vm.box = <Box>
      <Settings>
    end
  end
  

Code like this tends to get lengthy, and takes a lot of effort to create (copy-paste-edit-paste-edit...). It's also hard to mantain - you have to repeat the process for each VM you want to add, or for settings you have to mess with in multiple VMs. Besides, finding an error would be a pain...

Solution - variables, loops and conditionals

If you are a developer (and you likely are), you already noticed a repeating pattern, with predictable variations in key spots, in the code above. Well, Ruby is a programming language, isn't it?

Let's revisit some helpful Ruby concepts. This won't be a real class - just an overview of what we're about to use

We'll use ruby -e command, which runs Ruby code in the shell

Arrays and Hashes

Arrays are sets of objects. Easy as this: a list containing some items. You should put them inside square brackets, separated by commas.

Hashes are indexed sets. Each item has an index, called key, which ponts to a value. You should put them inside curly brackets, separated by commas.

We'll use class method, which returns the class from which the object is an instance
  $ ruby -e 'puts [-5, 3.14, "Text", :symbol].class'
  Array

  $ ruby -e 'puts ({1 => "b", "c" => 5.5, :something => "else"}.class)'
  Hash
  

Let's remind ourselves that, in Ruby, everything is an object. So the concept of types makes no sense. Array and Hash are classes.

Also, arrays and hashes can be nested (I divided in multiple lines for a better view):

  $ ruby -e 'puts [
    1,
    [2, 3, 4],
    {1 => "a"}
  ].class'
  Array

  $ ruby -e 'puts ({
    1 => [2, 3, 4],
    "c" => {1 => "a"},
    [1, 2] => "f",
    {1 => "a"} => 5
  }.class)'
  Hash
  

The each loop

It's easy to create repetition loops in Ruby using method each. When iterating an array, the loop repeats itself, with a variable which contains, in each execution, one array item. when iterating a hash, we use one variable for the keys and another one for the values.

The method is invoked with a syntax common in object-oriented languages: <Object>.<Method>, that is, <Array/Hash>.each. The each method syntax is:

  <Object>.each do |<variable(s)>|
    <Block which uses the variable(s)>
  end
  

Showing an array and a hash iterations:

  $ ruby -e '
    [1, 2, 3].each do |var|
      puts var
    end
  '
  1
  2
  3

  $ ruby -e '
    {1 => "a", 2 => "b", 3 => "c"}.each do |key, value|
      puts "Key: #{key} - Value: #{value}"
    end
  '
  Key: 1 - Value: a
  Key: 2 - Value: b
  Key: 3 - Value: c
  

Conditionals

Conditionals are the same thing in every language: a code block is executed if a set of conditions is met. In Ruby, the conditional statements are if, elsif, else and unless:

  $ ruby -e '
    puts "Type the age"
    age = gets().to_i
    unless age < 0 then
      if age >= 18 then
        puts "Adult"
      elsif age >= 12
        puts "Teen"
      else
        puts "Child"
      end
    else
      puts "?"
    end
  '
  

What does it have to do with the Vagrantfile?

With these resources, we can turn repeated chunks of code in a single general block, which uses variables for the changing data.

You can test codes below with vagrant status, vagrant up, vagrant ssh, etc., then delete the VMs with vagrant destroy. When in doubt, use vagrant help.

For example, let's create some VMs with the same settings, only changing names:

  Vagrant.configure("2") do |config|
    config.vm.define 'vm_1' do |vm|
      vm.vm.box = 'ARTACK/debian-jessie'
    end
    config.vm.define 'vm_2' do |vm|
      vm.vm.box = 'ARTACK/debian-jessie'
    end
    config.vm.define 'vm_3' do |vm|
      vm.vm.box = 'ARTACK/debian-jessie'
    end
  end
  

The code chunks are equal, except for the VM names. What about a loop iterating on a name list, running a single code block?

  Vagrant.configure("2") do |config|
    [
      'vm_1',
      'vm_2',
      'vm_3'
    ].each do |name|
      config.vm.define name do |vm|
        vm.vm.box = 'ARTACK/debian-jessie'
      end
    end
  end
  

Besides getting the code slimmer, see how easy it became to include, exclude and change virtual machines, by only changing array elements.

What if the VMs aren't equal? Simple: all we need is a hash, with VM names as keys, and the changing setting as a value:

  Vagrant.configure("2") do |config|
    {
      'vm_1' => 'ARTACK/debian-jessie',
      'vm_2' => 'ARTACK/debian-jessie',
      'vm_3' => 'centos/7'
    }.each do |name, box|
      config.vm.define name do |vm|
        vm.vm.box = box
      end
    end
  end
  

What if they have more than one changing setting? It happens that both hash keys and values can be virtually any object. What if these values are other hashes, with the settings names as keys and values as values themselves? We'll call these internal hashes 'confs' for our iteration:

  Vagrant.configure("2") do |config|
    {
      'vm_1' => {
        :box => 'ARTACK/debian-jessie',
        :ip => '192.168.1.2'
      },
      'vm_2' => {
        :box => 'ARTACK/debian-jessie',
        :ip => '192.168.1.3'
      },
      'vm_3' => {
        :box => 'centos/7',
        :ip => '192.168.1.4'
      }
    }.each do |name, confs|
      config.vm.define name do |vm|
        vm.vm.box = confs[:box]
        vm.vm.network "private_network", ip: confs[:ip]
      end
    end
  end
  

If the hash of hashes is getting difficult to visualize, let's use a command line to unwrap it:

  $ ruby -e '
    {
      "vm_1" ={
        :box ="ARTACK/debian-jessie",
        :ip ="192.168.1.2"
      },
      "vm_2" ={
        :box ="ARTACK/debian-jessie",
        :ip ="192.168.1.3"
      },
      "vm_3" ={
        :box ="centos/7",
        :ip ="192.168.1.4"
      }
    }.each do |name, confs|
      puts "Key: #{name} - Value: #{confs}"
    end
  '
  Key: vm_1 - Value: {:box=>"ARTACK/debian-jessie", :ip=>"192.168.1.2"}
  Key: vm_2 - Value: {:box=>"ARTACK/debian-jessie", :ip=>"192.168.1.3"}
  Key: vm_3 - Value: {:box=>"centos/7", :ip=>"192.168.1.4"}
  

See how the config hashes are item values for each main hash item.

What if some setting is not present in all VMs? That's when we call conditionals into action:

  Vagrant.configure("2") do |config|
    {
      'vm_1' => {
        :box => 'ARTACK/debian-jessie',
        :ip => '192.168.1.2'
      },
      'vm_2' => {
        :box => 'ARTACK/debian-jessie',
        :ip => '192.168.1.3'
      },
      'vm_3' => {
        :box => 'centos/7',
      }
    }.each do |name, confs|
      config.vm.define name do |vm|
        vm.vm.box = confs[:box]
        vm.vm.network "private_network", ip: confs[:ip] if confs.has_key?(:ip)
      end
    end
  end
  

The key :ip absense in the VM 'mv_3' settings hash causes directive if confs.has_key?(:ip) return false, so vm.network setting is not included. That is: 'mv_3' has no private network IP.

How far can we get?

We can also include multiple settings of the same type (like more IPs in the same VM), using arrays as the setting value, use loops for global settings, and so on. The practical limit is readability - hashes with too many nesting levels make data too hard to understand, and any analysis or change would take too much work. And we intend the very opposite.


Now code is well organized, but still too cluttered due to the huge hash hung in the loop beginning. Time to go to part 3.