Creating The Perfect Software Development Environment: Day 10

Personal Customizations

To this point, everything we have learned was directed at creating a Vagrant box just the way we like it. If you are creating a box that is intended to be used by an entire team or organization, the box will have to remain free of personal touches, such as Git configuration and Bash aliases. What we need is a way to apply personal customizations to the shared box. Thankfully, Vagrant has a mechanism for customizing the box at creation time.

Vagrant Provisioning

Provisioners in Vagrant allow you to automatically install software, alter configurations, and more on the machine as part of the vagrant up process.

This is useful since boxes typically are not built perfectly for your use case. Of course, if you want to just use vagrant ssh and install the software by hand, that works. But by using the provisioning systems built-in to Vagrant, it automates the process so that it is repeatable. Most importantly, it requires no human interaction, so you can vagrant destroy and vagrant up and have a fully ready-to-go work environment with a single command. Powerful.

Vagrant gives you multiple options for provisioning the machine, from simple shell scripts to more complex, industry-standard configuration management systems.

Vagrant Provisioning provides us with many options but we will restrict our exploration to the 3 I find most useful.

Vagrant Provisioning via Bash

The simplest way to customize your box is to run a shell script. Remember, we need to write the Vagrant file in such a way that it can be seamlessly adjusted by each user. To do that, we rely on environment variables which will tell us the location of the scripts to run. The scripts can be either local on the host machine or an remote web server, such as a GitHub Gist.

Snippet from the vagrantfile


  script_file_location = ENV['CUSTOM_SHELL_SCRIPT']
  puts 'Running custom script: ' + script_file_location
  config.vm.provision "shell" do |custom|
      custom.name = "Personal touches via Bash"
      custom.path = script_file_location
      custom.privileged = false
  end
           

What we've done in the above snippet of a our vagrantfile is to add a little bit of Ruby code that checks the environment for the location of a Bash script to run.

Custom Bash Script


#!/usr/bin/env bash

echo "Custom script invoked using the $(whoami) account."

touch ~/custom-script-was-here.txt
            

When the box is created, the custom provisioning script is run and a file is left in the home directory. After that, the only way to get the provisioners to run again is to use vagrant provision, which is not normally needed.

Custom Bash script is invoked.
Custom Bash script is invoked.
The results of the custom script.
The results of the custom script.

Vagrant Provisioning via Docker

If your team is using Docker, and who isn't these days, you can have Vagrant install containers at box creation time. Perhaps you love to use Vault to store secrets, but the rest of your team isn't quite there yet, you can have Vagrant automatically spin up the Docker version of Vault for you. Although very powerful and convenient, it is not amenable to templating like the shell script was. You can't know ahead of time how many containers a developer wants to install and what arguments the container might need. This technique requires editing of the stock vagrantfile.

Snippet from the vagrantfile


  puts 'Installing Docker container'
  config.vm.provision "docker" do |custom|
      custom.run "vault", args: "--cap-add=IPC_LOCK", auto_assign_name: true, daemonize: true, restart: "always"
  end
          
Docker is invoked.
Docker is invoked.
The results of the Docker provisioner.
The results of the Docker provisioner.

Vagrant Provisioning via Ansible

The final provisioner we'll discuss is the Ansible Local Provisioner. As we've already seen, Ansible is very powerful and can easily customize the box in any way we see fit. Just like when we constructed the box itself, we'll upload our Ansible files into the guest and let Vagrant run ansible -playbook.

There are several options to the Ansible provisioner, giving us many ways to use it. For this example, we'll use the File Provisioner to upload our playbook into the guest. The File Provisioner can also upload entire directories which is useful for more complicated Ansible configurations involving template files and roles.

Snippet from the vagrantfile


  playbook_location = ENV['CUSTOM_PLAYBOOK']
  puts 'Uploading custom playbook: ' + playbook_location
  config.vm.provision "file", source: playbook_location, destination: "/tmp/ansible/playbook.yml"
  puts 'Running custom playbook: /tmp/ansible/playbook.yml'
  config.vm.provision "ansible_local" do |custom|
      custom.playbook = "/tmp/ansible/playbook.yml"
      custom.install = false
      custom.provisioning_path = "/tmp/ansible"
      custom.tmp_path = "/tmp/vagrant-ansible"
      custom.become = false
      custom.become_user = "root"
      custom.compatibility_mode = "auto"
      custom.verbose = true
      custom.limit = "localhost"
      custom.raw_arguments = ["--connection=local","--inventory=localhost,"]
  end
          

Ansible Playbook


---
- name: Install Tools
  hosts: localhost
  become: true
  tasks:
      - name: Favorite Tools
        apt:
            name        : "{{ item }}"
            state       : present
            update_cache: yes
        with_items:
            - "htop"
         
Ansible is invoked.
Ansible is invoked.
The results of the Ansible provisioner.
The results of the Ansible provisioner.

Conclusion

Today we learned how use three different Vagrant provisioners to customize a box to our liking. This allows for the creation of a more general box for the entire organization while still allowing individuals to make automated changes. The use of environment variables allows the Vagrant file to remain static and still apply personal customizations. I encourage you to experiment with the different provisioners and find the combination that works for you and your organization.

Full Vagrant File


# -*- mode: ruby -*-
# vi: set ft=ruby :

Vagrant.require_version ">= 2.0.4"

Vagrant.configure(2) do |config|
  config.ssh.username = "vagrant"
  config.ssh.password = "vagrant"

  config.vm.synced_folder ".", "/vagrant", disabled: true

  config.vm.provider "virtualbox" do |v|
      v.gui = true
  end

  # customizations via Bash
  script_file_location = ENV['CUSTOM_SHELL_SCRIPT']
  puts 'Running custom script: ' + script_file_location
  config.vm.provision "shell" do |custom|
      custom.name = "Personal touches via Bash"
      custom.path = script_file_location
      custom.privileged = false
  end

  # customizations via Docker
  puts 'Installing Docker container'
  config.vm.provision "docker" do |custom|
      custom.run "vault", args: "--cap-add=IPC_LOCK", auto_assign_name: true, daemonize: true, restart: "always"
  end

  # customizations via Ansible
  playbook_location = ENV['CUSTOM_PLAYBOOK']
  puts 'Uploading custom playbook: ' + playbook_location
  config.vm.provision "file", source: playbook_location, destination: "/tmp/ansible/playbook.yml"
  puts 'Running custom playbook: /tmp/ansible/playbook.yml'
  config.vm.provision "ansible_local" do |custom|
      custom.playbook = "/tmp/ansible/playbook.yml"
      custom.install = false
      custom.provisioning_path = "/tmp/ansible"
      custom.tmp_path = "/tmp/vagrant-ansible"
      custom.become = false
      custom.become_user = "root"
      custom.compatibility_mode = "auto"
      custom.verbose = true
      custom.limit = "localhost"
      custom.raw_arguments = ["--connection=local","--inventory=localhost,"]
  end

  config.vm.define "bionic-xubuntu" do |server|
      server.vm.provider "virtualbox" do |v|
          v.gui = true
          v.name = "packer.bionic-xubuntu"
      end
      server.vm.box = "bionic-xubuntu"
  end

end