Using Vagrant AWS with Capistrano

Vagrant 1.1 was recently released, adding support for virtualization providers other than VirtualBox. Among the providers now available is one for AWS. In switching my Vagrant workflow from VirtualBox to AWS, I ran into a problem; and in solving it, I discovered a better way to integrate Vagrant with Capistrano.

1. Vagrant Setup

Vagrant 1.1 was released recently. This release adds support for provider plugins, including a new, freely available provider for AWS. Rather than using VirtualBox on your local machine as the virtualization provider, you can now provision Vagrant-managed VMs in the cloud. This makes it much easier to try out things that require more resources like multi-VM environments and VMs requiring lots of RAM.

No Longer Distributed as a Gem

While Vagrant was initally distributed as a Ruby gem, Vagrant 1.0, introduced packages as the preferred installation method. Now with Vagrant 1.1+, it is no longer distributed as a gem. For more on why this change was made, see Mitchell’s blog.

1.1.x Installer Downloads Available at vagrantup.com.

To get Vagrant, download an installer from vagrantup.com. Once you’ve installed Vagrant, you can install the vagrant-aws provider: $ vagrant plugin install vagrant-aws

2. AWS Setup

Set up Account / Billing Info

To use the vagrant-aws provider, you’ll need an AWS account. If you don’t have one, you can set one up here.

Create an IAM User

You’ll probably also want to create an IAM user specificly for use with Vagrant. This allows you to limit access and revoke it if the account is compromised. You can find more info on IAM users and best practices for managing access to your AWS account here.

Keypair

Once you’ve crated an IAM user and downloaded the API keys, you’ll also want to generate a new SSH keypair from the EC2 management console. Name that ‘vagrant’, too, and save it to ~/.ssh/aws/vagrant.pem.

Security Group

Finally, you should also set up a separate Security Group to use with these VMs. You’ll need access to at least port 22 for SSH, though opening port 80 and 433 for HTTP(S) traffic might also be useful depending on your particular needs.

3. Project Setup

To demonstrate how Vagrant (and this new provider) might be used in the context of building infrastructure with Chef, let’s start with the shell of a project laid out as Justin and I described previously in our posts Chef Solo with Capistrano and Simplifying Chef Solo Cookbook Management with Berkshelf.

Vagrant Box Setup

We’ll assume we already have Ruby 1.9.3 and Bundler installed. We’ll also assume that we have an existing project laid out something like described above.

Let’s start by adding a dummy box to Vagrant that will let us work with the AWS provider. The new box format requires only that boxes contain a metadata.json file specifying which provider to use. Beyond that, the provider is free to require other files of its own. The Virtualbox provider’s box format includes VMDK disk images for example. The AWS provider does not require any disk images, but allows for the inclusion of a Vagrantfile specifying some default values. Here we can use the empty example box provided in the vagrant-aws project repo — we’ll specify all the values we need in our project’s Vagrantfile.

Add the dummy box to Vagrant: $ vagrant box add dummy https://github.com/mitchellh/vagrant-aws/raw/master/dummy.box

And then see that it’s now available for use: $ vagrant box list

Now that we’ve imported our dummy box, we can add a Vagrantfile to our project. $ vagrant init dummy

Let’s edit this Vagrantfile to include our API keys, an AMI to use, and a few other details:

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

Vagrant.configure("2") do |config|
  config.vm.box = "dummy"

  config.vm.provider :aws do |aws|
    aws.access_key_id = ENV['AWS_ACCESS_KEY_ID']
    aws.secret_access_key = ENV['AWS_SECRET_ACCESS_KEY']
    aws.keypair_name = "vagrant"
    aws.ssh_private_key_path = "~/.ssh/aws/vagrant.pem"

    aws.ami = "ami-7747d01e"
    aws.ssh_username = "ubuntu"
  end
end

We should now be able to start it up: $ vagrant up

And log in: $ vagrant ssh

We can terminiate the instance by running vagrant destroy. (We should be able to see all of this from the AWS Console.)

4. Capistrano Changes

In order to incorporate Vagrant in our project, we’ll make a new Capistrano stage for it. Let’s create a file config/deploy/vagrant.rb with the contents:

set :environment_name, "vagrant"
set :ssh_config, "var/#{environment_name}_ssh_config"
set :ssh_host, "default"
ssh_options[:config] = [ssh_config]
server ssh_host, :app, :web, :db
set :server_ip, ssh_host

set :chef_binary, "/usr/bin/chef-solo"

Let’s also make a few changes to config/deploy.rb so it looks like:

require "bundler/capistrano"
require "capistrano/ext/multistage"
set :stages, %w(vagrant)
set :default_stage, "vagrant"
default_run_options[:pty] = true

set :application, "bootstrapper"
set :repository,  "."
set :scm, :none

namespace :ssh do

  desc "Generate var/vagrant_ssh_config."
  task :generate_config do
    puts "Generating #{ssh_config}..."
    system("vagrant ssh-config > #{ssh_config}")
  end

  desc "Destroy var/vagrant_ssh_config."
  task :destroy_config do
    puts "Destroying #{ssh_config}..."
    system("rm #{ssh_config}")
  end
 
  desc "Pretty-print SSH config."
  task :show_config do
    require 'PP'
    netssh_config = Net::SSH::Config.for(ssh_host, [ssh_config])
    pp netssh_config
  end

  desc "SSH in."
  task :default do
    system("ssh -F #{ssh_config} #{ssh_host}")
  end
end

namespace :bootstrap do

  desc "Install Chef."
  task :default do
    set :default_shell, "bash"
    set :user, Net::SSH::Config.for(ssh_host, [ssh_config])[:user]
    set :id_file, Net::SSH::Config.for(ssh_host, [ssh_config])[:keys][0]
    set :hostname, Net::SSH::Config.for(ssh_host, [ssh_config])[:host_name]
    if exists?(:id_file)
      system("cd chef && knife bootstrap --bootstrap-version '10.16.2' -d chef-solo -x #{user} -i ../#{id_file} --sudo #{hostname}")
    else
      system("cd chef && knife bootstrap --bootstrap-version '10.16.2' -d chef-solo -x #{user} --sudo #{hostname}")
    end
  end
end

namespace :berks do

  desc "Install cookbooks from the Berksfile to chef/cookbooks/."
  task :install do
    system("berks install --path chef/cookbooks/")
  end
end

namespace :chef do
 
  desc "Upload chef/ and run chef solo against it."
  task :default do
    set :user, Net::SSH::Config.for(ssh_host, [ssh_config])[:user]
    set :default_shell, "bash"
    system("tar czf 'chef.tar.gz' -C chef/ .")
    upload("chef.tar.gz","/home/#{user}",:via => :scp)
    run("rm -rf /home/#{user}/chef")
    run("mkdir -p /home/#{user}/chef") 
    run("tar xzf 'chef.tar.gz' -C /home/#{user}/chef")
    sudo("/bin/bash -c 'cd /home/#{user}/chef && #{chef_binary} -c solo.rb -j vagrant.json'")
  end
end

namespace :vg do
  
  desc "Boot Vagrant VM & generate SSH config."
  task :up do
    system("vagrant up --provider=aws")
    find_and_execute_task("ssh:generate_config")
  end

  desc "Destroy Vagrant VM & remove SSH config."
  task :destroy do
    system("vagrant destroy -f")
    find_and_execute_task("ssh:destroy_config")
  end

end

By using Vagrant’s ssh-config command, we can dynamically generate the config file our vagrant capistrano stage is based on. This allows us to point that stage at whatever host Vagrant has spun up for us at the time.

By taking advantage of vagrant ssh-config and the fact that Capistrano uses Net:SSH (which can parse ssh config files), we can make a dynamic stage pointing to any VM Vagrant is currently running.