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.
- TL;DR
- Problem - code repetition
- Solution - variables, loops and conditionals
- What does it have to do with the Vagrantfile?
- 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 withvagrant status
,vagrant up
,vagrant ssh
, etc., then delete the VMs withvagrant destroy
. When in doubt, usevagrant 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.