Friday, March 25, 2016

Easy Rake-based Deployment for Git-hosted Rails Apps

I searched a lot of places for an easy way to automate my deployments for OpenStrokes, a website I've been working on. Some things were just too complex (capistrano) and some were way too simple (StackOverflow answers that didn't do everything, or didn't check for errors).

So, as most people do, I wrote my own. Hopefully this short rake task can help you as well. This assumes that your application server has your app checked out as a clone of some git repo you push changes to and that you are running under passenger. When I want to deploy, I log in to my production server, cd to my app repo, and then run:

rake myapp:deploy

For just strictly view updates, it completes in 3 seconds or less. There are several things it does, in addition to checking for errors:

  • Checks to make sure the app's git checkout isn't dirty from any local edits.
  • Fetches the remote branch and checks if there are any new commits, exits if not.
  • Tags the current production code base before pulling the changes.
  • Does a git pull with fast-forward only (to avoid unexpected merging).
  • Checks if there are any new gems to install via bundle (checks for changes in Gemfile and Gemfile.lock).
  • Checks if there are any database migrations that need to be done (checks for changes to db/schema.db db/migrations/*).
  • Checks for possible changes to assets and precompiles if needed (checks Gemfile.lock and app/assets/*).
  • Restarts passenger to pick up the changes.
  • Does a HEAD request on / to make sure it gets an expected 200 showing the server is running without errors.

The script can also take a few arguments:

  • :branch Git branch, defaults to master
  • :remote Git remote, defaults to origin
  • :server_url URL for HEAD request to check server after completion

Note, if the task encounters an error, you have to manually complete the deploy. You should not rerun the task.

Any finally, here is the task itself. You can save this to lib/tasks/myapp.rb

# We can't use Rake::Task because it can fail when things are mid
# upgrade

require "net/http"

def do_at_exit(start_time)
  puts "Time: #{(Time.now - start_time).round(3)} secs"
end

def start_timer
  start_time = Time.now
  at_exit { do_at_exit(start_time) }
end

namespace :myapp do
  desc 'Deployment automation'
  task :deploy, [:branch, :remote, :server_url] do |t, args|
    start_timer

    # Arg supercedes env, which supercedes default
    branch = args[:branch] || ENV['DEPLOY_BRANCH'] || 'master'
    remote = args[:remote] || ENV['DEPLOY_REMOTE'] || 'origin'
    server_url = args[:server_url] || ENV['DEPLOY_SERVER_URL'] || 'http://localhost/'

    puts "II: Starting deployment..."

    # Check for dirty repo
    unless system("git diff --quiet")
      puts "WW: Refusing to deploy on a dirty repo, exiting."
      exit 1
    end

    # Update from remote so we can check for what to do
    system("git fetch -n #{remote}")

    # See if there's anything new at all
    if system("git diff --quiet HEAD..#{remote}/#{branch} --")
      puts "II: Nothing new, exiting"
      exit
    end

    # Tag this revision...
    tag = "prev-#{DateTime.now.strftime("%Y%m%dT%H%M%S")}"
    system("git tag -f #{tag}")

    # Pull in the changes
    if ! system("git pull --ff-only #{remote} #{branch}")
      puts "EE: Failed to fast-forward to #{branch}"
      exit 1
    end

    # Base command to check for differences
    cmd = "git diff --quiet #{tag}..HEAD"

    if system("#{cmd} Gemfile Gemfile.lock")
      puts "II: No updates to bundled gems"
    else
      puts "II: Running bundler..."
      Bundler.with_clean_env do
        if ! system("bundle install")
          puts "EE: Error running bundle install"
          exit 1
        end
      end
    end

    if system("#{cmd} db/schema.rb db/migrate/")
      puts "II: No db changes"
    else
      puts "II: Running db migrations..."
      # We run this as a sub process to avoid errors
      if ! system("rake db:migrate")
        puts "EE: Error running db migrations"
        exit 1
      end
    end

    if system("#{cmd} Gemfile.lock app/assets/")
      puts "II: No changes to assets"
    else
      puts "II: Running asset updates..."
      if ! system("rake assets:precompile")
        puts "EE: Error precompiling assets"
        exit 1
      end
      system("rake assets:clean")
    end

    puts "II: Restarting Passenger..."
    FileUtils.touch("tmp/restart.txt")

    puts "II: Checking HTTP response code..."

    uri = URI.parse(server_url)
    res = nil

    Net::HTTP.start(uri.host, uri.port, :use_ssl => uri.scheme == 'https') do |http|
      req = Net::HTTP::Head.new(uri, {'User-Agent' => 'deploy/net-check'})
      res = http.request req
    end

    if res.code != "200"
      puts "EE: Server returned #{res.code}!!!"
      exit 1
    else
      puts "II: Everything appears to be ok"
    end
  end
end

Here's an example of the command output:

$ rake myapp:deploy
II: Starting deployment...
remote: Counting objects: 15, done.
remote: Compressing objects: 100% (8/8), done.
remote: Total 8 (delta 6), reused 0 (delta 0)
Unpacking objects: 100% (8/8), done.
From /home/user/myapp
   efee45c..e5468c1  master     -> origin/master
From /home/user/myapp
 * branch            master     -> FETCH_HEAD
Updating efee45c..e5468c1
Fast-forward
 app/views/users/_display.html.erb     | 7 +++++--
 public/svg/badges/caretakers-club.svg | 1 -
 2 files changed, 5 insertions(+), 3 deletions(-)
 delete mode 100644 public/svg/badges/caretakers-club.svg
II: No updates to bundled gems
II: No db changes
II: No changes to assets
II: Restarting Passenger...
II: Checking HTTP response code...
II: Everything appears to be ok
Time: 3.031 secs