7 min read

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?