Blog |

Writing a simple deploy script with Fabric and @roles

Writing a simple deploy script with Fabric and @roles
Table of Contents

I first heard about Fabric a couple years ago while at Lolapps and liked
the idea of:

  • writing deployment and sysadmin scripts in a language other than Bash
  • that language being Python, which we used everywhere else

but we already had a huge swath of shell scripts that worked well (and truth be told, Bash isn’t
really that bad). But now that we have at clean slate for Rollbar, Fabric it is.

I wanted a simple deployment script that would do the following:

  1. check to make sure it’s running as the user “deploy” (since that’s the user that has ssh keys set up
    and owns the code on the remote machines)
  2. for each webserver:
    1. git pull
    2. pip install -r requirements.txt
    3. in series, restart each web process
  3. make an HTTP POST to our deploys api to record that the deploy completed
    successfully

Here’s my first attempt:

import sys

from fabric.api import run, local, cd, env, roles, execute
import requests

env.hosts = ['web1', 'web2']

def deploy():
    # pre-roll checks
    check_user()

    # do the roll.
    update_and_restart()

    # post-roll tasks
    rollbar_record_deploy()

def update_and_restart():
    code_dir = '/home/deploy/www/mox'
    with cd(code_dir):
        run("git pull")
        run("pip install -r requirements.txt")
        run("supervisorctl restart web1")
        run("supervisorctl restart web2")

def check_user():
    if local('whoami', capture=True) != 'deploy':
        print "This command should be run as deploy. Run like: sudo -u deploy fab deploy"
        sys.exit(1)

def rollbar_record_deploy():
    # read access_token from production.ini
    access_token = local("grep 'rollbar.access_token' production.ini | sed 's/^.* = //g'",
        capture=True)

    environment = 'production'
    local_username = local('whoami', capture=True)
    revision = local('git log -n 1 --pretty=format:"%H"', capture=True)

    resp = requests.post('https://api.rollbar.com/api/1/deploy/', {
        'access_token': access_token,
        'environment': environment,
        'local_username': local_username,
        'revision': revision
    }, timeout=3)

    if resp.status_code == 200:
        print "Deploy recorded successfully"
    else:
        print "Error recording deploy:", resp.text

Looks close-ish, right? It knows which hosts to deploy to, checks that it’s running as deploy,
updates and restarts each host, and records the deploy. Here’s the output:

$ sudo -u deploy fab deploy
(env-mox)[brian@dev mox]$ sudo -u deploy fab deploy
[sudo] password for brian:
[web1] Executing task 'deploy'
[localhost] local: whoami
[web1] run: git pull
[web1] out: remote: Counting objects: 8, done.
[web1] out: remote: Compressing objects: 100% (4/4), done.
[web1] out: remote: Total 6 (delta 4), reused 4 (delta 2)
[web1] out: Unpacking objects: 100% (6/6), done.
[web1] out: From github.com:brianr/mox
[web1] out:    c731b57..1d365e0  master     -> origin/master
[web1] out: Updating c731b57..1d365e0
[web1] out: Fast-forward
[web1] out:  fabfile.py |    8 ++++----
[web1] out:  1 file changed, 4 insertions(+), 4 deletions(-)

[web1] run: pip install -r requirements.txt
[web1] out: Requirement already satisfied (use --upgrade to upgrade): Beaker==1.6.3 in /home/deploy/env-mox/lib/python2.7/site-packages (from -r requirements.txt (line 1))

[web1] out: Cleaning up...

[web1] run: supervisorctl restart web1
[web1] out: web1: stopped
[web1] out: web1: started

[web1] run: supervisorctl restart web2
[web1] out: web2: stopped
[web1] out: web2: started

[localhost] local: grep 'rollbar.access_token' production.ini | sed 's/^.* = //g'
[localhost] local: whoami
[localhost] local: git log -n 1 --pretty=format:"%H"
Deploy recorded successfully. Deploy id: 307
[web2] Executing task 'deploy'
[localhost] local: whoami
[web2] run: git pull
[web2] out: remote: Counting objects: 8, done.
[web2] out: remote: Compressing objects: 100% (4/4), done.
[web2] out: remote: Total 6 (delta 4), reused 4 (delta 2)
[web2] out: Unpacking objects: 100% (6/6), done.
[web2] out: From github.com:brianr/mox
[web2] out:    c731b57..1d365e0  master     -> origin/master
[web2] out: Updating c731b57..1d365e0
[web2] out: Fast-forward
[web2] out:  fabfile.py |    8 ++++----
[web2] out:  1 file changed, 4 insertions(+), 4 deletions(-)

[web2] run: pip install -r requirements.txt
[web2] out: Requirement already satisfied (use --upgrade to upgrade): Beaker==1.6.3 in /home/deploy/env-mox/lib/python2.7/site-packages (from -r requirements.txt (line 1))

[web2] out: Cleaning up...

[web2] run: supervisorctl restart web1
[web2] out: web1: stopped
[web2] out: web1: started

[web2] run: supervisorctl restart web2
[web2] out: web2: stopped
[web2] out: web2: started

[localhost] local: grep 'rollbar.access_token' production.ini | sed 's/^.* = //g'
[localhost] local: whoami
[localhost] local: git log -n 1 --pretty=format:"%H"
Deploy recorded successfully. Deploy id: 308

Done.
Disconnecting from web2... done.
Disconnecting from web1... done.

Lots of good things happening. But it’s doing the whole process - check_user,
update_and_restart, rollbar_record_deploy - twice, once for each host. The duplicate
check_user just slows things down, but the duplicate rollbar_record_deploy is going to mess with
our deploy history, and it’s only going to get worse as we add more servers.

Fabric’s solution to this, described in their
docs, is “roles”. We can map hosts to
roles, then decorate tasks with which roles they apply to. Here we replace the env.hosts
declaration with env.roledefs, decorate update_and_restart with @roles, and call
update_and_restart with execute so that the @roles decorator is honored:

import sys

from fabric.api import run, local, cd, env, roles, execute
import requests

env.roledefs = {
    'web': ['web1', 'web2']
}

def deploy():
    # pre-roll checks
    check_user()

    # do the roll.
    # execute() will call the passed-in function, honoring host/role decorators.
    execute(update_and_restart)

    # post-roll tasks
    rollbar_record_deploy()

@roles('web')
def update_and_restart():
    code_dir = '/home/deploy/www/mox'
    with cd(code_dir):
        run("git pull")
        run("pip install -r requirements.txt")
        run("supervisorctl restart web1")
        run("supervisorctl restart web2")

def check_user():
    if local('whoami', capture=True) != 'deploy':
        print "This command should be run as deploy. Run like: sudo -u deploy fab deploy"
        sys.exit(1)

def rollbar_record_deploy():
    # read access_token from production.ini
    access_token = local("grep 'rollbar.access_token' production.ini | sed 's/^.* = //g'",
        capture=True)

    environment = 'production'
    local_username = local('whoami', capture=True)
    revision = local('git log -n 1 --pretty=format:"%H"', capture=True)

    resp = requests.post('https://api.rollbar.com/api/1/deploy/', {
        'access_token': access_token,
        'environment': environment,
        'local_username': local_username,
        'revision': revision
    }, timeout=3)

    if resp.status_code == 200:
        print "Deploy recorded successfully"
    else:
        print "Error recording deploy:", resp.text

Here’s the output:

(env-mox)[brian@dev mox]$ sudo -u deploy fab deploy
[sudo] password for brian:
[localhost] local: whoami
[web1] Executing task 'update_and_restart'
[web1] run: git pull
[web1] out: Already up-to-date.

[web1] run: pip install -r requirements.txt
[web1] out: Requirement already satisfied (use --upgrade to upgrade): Beaker==1.6.3 in /home/deploy/env-mox/lib/python2.7/site-packages (from -r requirements.txt (line 1))

[web1] out: Cleaning up...

[web1] run: supervisorctl restart web1
[web1] out: web1: stopped
[web1] out: web1: started

[web1] run: supervisorctl restart web2
[web1] out: web2: stopped
[web1] out: web2: started

[web2] Executing task 'update_and_restart'
[web2] run: git pull
[web2] out: Already up-to-date.

[web2] run: pip install -r requirements.txt
[web2] out: Requirement already satisfied (use --upgrade to upgrade): Beaker==1.6.3 in /home/deploy/env-mox/lib/python2.7/site-packages (from -r requirements.txt (line 1))

[web2] out: Cleaning up...

[web2] run: supervisorctl restart web1
[web2] out: web1: stopped
[web2] out: web1: started

[web2] run: supervisorctl restart web2
[web2] out: web2: stopped
[web2] out: web2: started

[localhost] local: grep 'rollbar.access_token' production.ini | sed 's/^.* = //g'
[localhost] local: whoami
[localhost] local: git log -n 1 --pretty=format:"%H"
Deploy recorded successfully. Deploy id: 309

Done.
Disconnecting from web2... done.
Disconnecting from web1... done.

That’s more like it. Since env.hosts is not set, the undecorated tasks just run locally (and only
once), and the @roles('web')-decorated task runs for each web host.

"Rollbar allows us to go from alerting to impact analysis and resolution in a matter of minutes. Without it we would be flying blind."

Error Monitoring

Start continuously improving your code today.

Get Started Shape