Using Bolt and Vagrant to Prototype Infrastructure

Vagrant is a great tool from Hashicorp (makers of tools such as Terraform and Vault) for prototyping infrastructure for applications. It will let you start up one or more VMs of any type with any arbitrary configuration.

Bolt is a tool from Puppet (makers of, well, Puppet) that makes it easy to orchestrate actions across systems using any combination of commands, scripts, or even Puppet code. One of the best features of Bolt is its inventory plugins. In this case, we’re going to examine the bolt_vagrant inventory plugin which lets us use Bolt against Vagrant VMs without hard-coding IP addresses or, in some cases, even names. Best of all, you can test Bolt tasks and plans in a Vagrant environment, then move on to real systems by simply changing your inventory.

Getting Started

First, you’ll need Bolt and Vagrant installed. In addition, you’ll need a Vagrant provider like VirtualBox.

Building VMs

Let’s start out by building a couple of VMs. Create an empty directory to work from and cd into it. You can run vagrant init centos/stream8 to create a Vagrantfile with a CentOS Stream 8 VM configured. From there, you can edit the Vagrantfile to add additional VMs. Here’s an example that you can use:

 Vagrant.configure("2") do |config|
    config.vm.define "centos" do |centos|
      centos.vm.box = "centos/stream8"
      centos.vm.provider "virtualbox" do |vb 
        vb.memory = "1024"
      end
    end

    config.vm.define "windows" do |windows|.  
      windows.vm.box = "gusztavvargadr/windows-server"
      windows.vm.provider "virtualbox" do |vb
         vb.memory = "1024"
      end
 end

This configuration gives us a CentOS Stream 8 VM and a Windows Server 2022 VM, both configured for 1GB RAM.

To start the VMs, run vagrant up.

Setting Up Bolt

Start out by running bolt project init. If the name of the directory you created isn’t acceptable as a Bolt project name (which has the same rules as Puppet class names), you can also supply a Bolt-friendly project name:

 $ bolt project init demo

This creates two files, bolt-project.yaml and inventory.yaml. More on those later.

Adding The BOLT_VAGRANT Module

Next we need to add the bolt_vagrant module to our project with bolt module add dylanratcliffe-bolt_vagrant. This updates bolt-project.yaml, creates a Bolt-managed Puppetfile, and installs the module (plus any dependencies) in the .modules directory.

The bolt_vagrant module adds a task that can be used to generate inventory for Bolt from the running Vagrant configuration.

To enable the bolt_vagrant inventory, we need to add the following to inventory.yaml:

 targets:
 - _plugin: task
   task: bolt_vagrant::targets

To demonstrate that this is working, run bolt inventory show -t all. You should see centos and windows in the output.

Running Ad-Hoc Commands

Let’s run a command across all of our VMs to start.

 $ bolt command run hostname -t all

(Note that we’re cheating a little here by running the hostname command, which works on both Linux and Windows.)

Running Scripts

We can also run arbitrary scripts against our VMs.

 $ echo hostname > myscript.ps1

 $ bolt script run myscript.ps1 -t all

(Now we’re really cheating here. On Windows, the ps1 extension will tell Bolt to use PowerShell to run the script. On Linux, the extension is ignored and the script is run by the default script interpreter, /bin/sh.)

Running Tasks

Bolt tasks are scripts with some associated metadata that is used to specify script arguments, etc.

Several tasks are shipped with Bolt. You can see the current list with bolt task show.

To add additional tasks, either install additional Puppet modules that contain tasks or create a directory named tasks and drop in the necessary files.

For example:

 tasks/hostname.json:

 {
   "puppet_task_version": 1,
      "supports_noop": false,
      "description": "Run hostname",
      "implementations": [
          { "name": "hostname.sh", "requirements": ["shell"] },
          { "name": "hostname.ps1", "requirements": ["powershell"] }
     ],
     "parameters": {
     }
}

 tasks/hostname.sh:

 #!/bin/sh

 hostname

 tasks/hostname.ps1:

 hostname

Once those files are in place, you can run the task on all nodes with the following command:

 $ bolt task run demo::hostname -t all

Plans

Bolt supports two different kinds of plans: plans written in YAML, and plans written in the Puppet language.

Plans can run a set of commands, scripts, tasks, or other plans in a set sequence on a list of targets.

You can drop plan files in a directory named plans in the Bolt project, but there is also a bolt command to generate a new plan template.

 $ bolt plan new demo::hostname1

That command will create a YAML plan. To create a Puppet-language plan, add --pp to the command.

 $ bolt plan new demo::hostname2 --pp

For demo::hostname1, we can modify plans/hostname1.yaml to look like this:

 description: Run hostname via a YAML plan
 parameters:
 targets:
   type: TargetSpec
   description: A list of targets to run actions on
   default: all
 steps:
 - message: Hello from demo::hostname1
 - name: command_step
   command: hostname
   targets: $targets
 return: $command_step

To get the same functionality out of demo::hostname2, modify plans/hostname2.pp to look like this:

 plan demo::hostname2 (
    TargetSpec $targets = 'all',
 )   {
    out::message('Hello from demo::hostname2')
    $command_result = run_command('hostname', $targets)
    return $command_result
 }

You can run these plans with

 $ bolt plan run demo::hostname1

or

 $ bolt plan run demo::hostname2

Note that we do not need to supply a list of targets for either of these plans because they default to a target list of all.

Using a Bolt Plan for Provisioning

With a couple of minor tweaks to our Vagrantfile, we can use a Bolt plan for provisioning.

First, we need to enable an experimental Vagrant feature - typed_triggers. We can do this by adding the following line to the beginning of our Vagrantfile:

 ENV['VAGRANT_EXPERIMENTAL'] = 'typed_triggers'

Next, we want to make sure that any Puppet modules that Bolt needs (like bolt_vagrant) are installed before we try to use them. Add the following to the Vagrant.configure(2) block:

 config.trigger.before [:up, :provision, :reload], type: :command do |trigger|.    
     trigger.info = 'Initializing bolt'
     trigger.run = { inline: 'bolt module install' }
end

Finally, we want to run our plan after all of the VMs have started:

 config.trigger.after [:up, :provision, :reload], type: :command do |trigger|.  
     trigger.info = 'Running bolt plan'
     trigger.run = { inline: 'bolt plan run demo' }
 end

Examples

You can find examples of this pattern on GitHub.

You can see Steve’s talk on this topic at the 2021 Ohio Linux Fest here. An earlier version of this blog was shared by Onyx Point, before Sicura spun out in 2021.