Automating your development environment with Ansible
Ansible is a systems automation tool, similar to Chef and Puppet but with a very different approach. Ansible doesn't by default use a client-server model or a push-pull management style, it doesn't leave any artifacts behind and doesn't need to install anything to run on the server.
Ansible is beautifully simple and brilliantly powerful at the same time which is why I'm using it to setup my local machine. Once you start automating your production environments it just makes sense to start automating your local environment. Github does it, @jtimberman does it, I do it. Once you have your playbook setup you can get up and running on your machine in an hour or two and it'll be setup exactly how you like it, it's beautiful.
To get started you'll need to get Ansible installed. I went with installing from source which is as simple as cloning from git and then installing a few items it needs. There's also a Homebrew formula to install Ansible if you prefer that.
$ git clone git://github.com/ansible/ansible.git
$ cd ./ansible
$ source ./hacking/env-setup
$ sudo easy_install pip
$ sudo pip install paramiko PyYAML jinja2
Ansible works by using various modules. There's modules for managing files, installing packages, setting up services, etc. I'm on a macbook so I'd prefer to use Homebrew to install packagess which Ansible has a module for. There's various packaging modules available, pick the one that works best for your system.
Ansible doesn't install Homebrew when we reference the homebrew module so we'll need to install it manually. You might be able to do this within an Ansible playbook but I went ahead and just set it up beforehand.
$ ruby -e "$(curl -fsSL https://raw.github.com/mxcl/homebrew/go)"
We're also going to use Homebrew to install various applications for us via the external command cask
which comes from homebrew-cask. Basically any application that comes as a .dmg, .zip or .pkg , you can use the cask command to install if there's a cask for it. If there isn't one yet, I'm sure the maintainers would love a pull request :). If you're familiar with Boxen, it's basically the same as the 'appdmg' provider.
$ brew tap phinze/homebrew-cask
$ brew install brew-cask
Setup a .ansible
folder within your home directory or wherever you'd like to store your configuration. I'd recommend putting this in git and storing it in a private repository on Github.
mkdir ~/.ansible
To let Ansible know which hosts to connect to, it uses an Inventory file. Ours will be pretty simple, we just need to tell it to use localhost and that it doesn't need ssh to communicate.
localhost ansible_connection=local
Now we can set up our playbook(cookbook in chef, manifest in puppet). Playbooks run in sequential order and use various modules to execute tasks. Playbooks are written in YAML format, using Jinja2 template tags in various places.
First, we'll tell it that it should run on all hosts and install a few homebrew packages.
---
- hosts: all
tasks:
- name: Install libraries with homebrew
homebrew: name={{ item }} state=present
with_items:
- wget
- apple-gcc42
- vim
- ack
- git
- rbenv
- ruby-build
- elasticsearch
- mysql
- tmux
- name: Start services at login
file: src=/usr/local/opt/{{ item }}/homebrew.mxcl.{{ item }}.plist path=~/Library/LaunchAgents/homebrew.mxcl.{{ item }}.plist state=link force=yes
with_items:
- mysql
- elasticsearch
- name: Setup launch agents for services
command: launchctl load {{ home }}/Library/LaunchAgents/homebrew.mxcl.{{ item }}.plist
with_items:
- mysql
- elasticsearch
To run that playbook, it's as simple as:
$ ansible-playbook playbook.yml -i hosts
Each task has a name which is a human friendly name to label the task with. homebrew
is the module that we're using and we specify the name of the package and that we want to be present/installed. Each module has various options, consult the documentation for the module that you'll be using. with_items
takes an array or a dictionary object and uses that to iterate over the current task. To access the current item within the loop, we just use the item
variable.
We can also store variables in our playbook, I'm going to store all of the applications that I want to install with brew-cask in a variable so I can reference it later.
---
- hosts: all
vars:
applications:
- google-chrome
- firefox
- sublime-text
- transmit
tasks:
- name: Check for installed apps(casks)
shell: brew cask list | grep {{ item }}
register: installed_applications
with_items: applications
ignore_errors: true
- name: Install apps with brew-cask
shell: brew cask install {{ item }}
with_items: applications
when: item not in installed_applications.results|map(attribute='stdout')
I've found the list of casks that I want to install by searching for them using brew cask search name-of-app
and then stored them in the applications
array. The first task iterates over my list of applications and greps to see if they exist in the output. The `ignore_errors` setting is because we're grep'ing for that application name and it's going to have an exist status of 0(success) when it exists and 1(failure) when it doesn't, there's probably a more efficient way to do this but I haven't found it yet. register
stores the value of this task in the named variable so that I can reference it later to conditionally install the application.
The next task actually installs the applications, if an application doesn't exist. when
is a conditional that you can define to determine when a task should run, we're using our installed_applications
variable from the previous task to determine that. The brackets are Jinja2 template tags and the pipes map the output of the previous function to a filter. In this case, all I care about is seeing if the stdout from the previous tasks contains the name of that application. If it doesn't, then we'll install it.
I've added a few more tasks to complete a basic setup of a Ruby development machine, here's the full playbook. I've placed comments throughout to explain some of the remaining parts of the playbook.
---
- hosts: all
vars:
home: /Users/nick # Your ~/
src_home: /Users/nick/src # Where your code lives
applications: # .dmg, .app, .zip type applications via brew-cask
- google-chrome
- firefox
- sublime-text
- transmit
projects: # An array of projects to get setup
- name: project-1 # This will be the pow host, project-1.dev in this case
git: git@github.com:owner/repo.git
path: project-1
pow: true # Setup a pow host for this project
- name: widgetco
git: git@heroku.com:widgetco.git
path: widgetco/chef
tasks:
- name: Install libraries with homebrew
homebrew: name={{ item }} state=present
with_items:
- wget
- apple-gcc42
- vim
- ack
- git
- rbenv
- ruby-build
- elasticsearch
- mysql
- tmux
# For any formula that has Caveats, run `brew info formula` to see what they are and add them to your playbook.
- name: Start services at login
file: src=/usr/local/opt/{{ item }}/homebrew.mxcl.{{ item }}.plist path=~/Library/LaunchAgents/homebrew.mxcl.{{ item }}.plist state=link force=yes
with_items:
- mysql
- elasticsearch
- name: Setup launch agents for services
command: launchctl load {{ home }}/Library/LaunchAgents/homebrew.mxcl.{{ item }}.plist
with_items:
- mysql
- elasticsearch
- name: Check if Pow is installed
stat: path={{ home }}/.pow
register: pow_installed
- name: Install Pow
shell: curl get.pow.cx | sh
when: pow_installed.stat.exists == false
- name: Clone dotfiles
# I set force and update to no so that if I have any working changes or changes that I haven't pushed up it doesn't reset my local history.
git: repo=git@github.com:nickhammond/{{ item }}.git dest={{ home }}/.{{ item }} force=no update=no
with_items:
- dotfiles
- name: Symlink dotfiles
file: path={{ home }}/.{{ item }} src={{ home }}/.dotfiles/{{ item }} state=link
with_items:
- gemrc
- gitconfig
- tmux.conf
- vimrc
- commands
- zshenv
- name: Load rbenv
lineinfile: dest={{ home }}/.zshenv line='eval "$(rbenv init -)"' regexp=eval.*rbenv
- name: Create src folder
file: path={{ src_home }} state=directory
- name: Create src/projects* directories
file: path={{ src_home }}/{{ item.name }} state=directory
with_items: projects
- name: Clone projects from github
git: repo={{ item.git }} dest={{ src_home }}/{{ item.path }}
with_items: projects
# Runs `rbenv versions` and stores the result in installed_rubies.
# We'll use the variable later to figure out which versions are installed
# and which ones we still need
- name: Installed rubies
shell: rbenv versions
register: installed_rubies
# This assumes that all of your projects have a .ruby-version
# and goes through and collects all of the rubies needed.
- name: Project specific rubies needed
shell: cat {{ src_home }}/{{ item.path }}/.ruby-version
register: rubies_needed
with_items: projects
# Here we use rubies_needed and installed_rubies to figure out what
# rbenv/ruby-build needs to install. There's an issue with this if you
# have two projects that have the same version, I might just end up
# specifying the ruby version along with the project instead.
- name: Install .ruby-versions
shell: rbenv install {{ item.stdout }}
with_items: rubies_needed.results
when: installed_rubies.stdout.find(item.stdout) == -1
# Sets up a symlink for pow for every project that has pow: true set
- name: Pow host for projects
file: path={{ home }}/.pow/{{ item.name }} src={{ src_home }}/{{ item.path }} state=link
when: item.pow|default(false)
with_items: projects
- name: Check for installed apps
shell: brew cask list | grep {{ item }}
register: installed_applications
with_items: applications
ignore_errors: true
- name: Install apps with brew-cask
shell: brew cask install {{ item }}
with_items: applications
when: item not in installed_applications.results|map(attribute='stdout')
Now we have a basic Ruby development environment setup on our machine that you can reuse or share with someone else. This is a simple example to get up and running with a single yaml file. For more complex setups, take a look at the best practices documentation for a better way to organize everything.
Are you automating your development environment? If so, how and what are you using?