Managing Rails Versions with Capistrano
Rails 1.2 is out! I can hear the bits screaming as they race down the wires. Time to get those apps upgraded! But first... You're gonna freeze your Rails apps to a specific Rails version, aren't you? Yes, of course I am, but how?
These are the questions I asked myself today as I prepared for the new release. I have newly-minted apps that I'd like to lock onto Rails 1.2, and I have "legacy" apps that will take some time to test and upgrade. I want to make doubly-sure my apps aren't at the mercy of system-wide Rails installs. Yes, we must be selfish when it comes to our Rails versions.
Having checked that off the to-do list, I then started wondering if I've been managing Rails versions correctly all along. Something didn't feel right, so I began asking around to see what other folks I respect are doing. Here's what I found...
Freezing
Any of the following commands will copy a version of Rails into your application's
vendor/rails directory:
rake rails:freeze:edge rake rails:freeze:edge REVISION=xyz rake rails:freeze:edge TAG=xyz rake rails:freeze:gems
The exact version comes from the Rails Subversion repository or, in the case of the last command, the version of Rails currently installed as a gem on your machine.
Then, when you fire up your application, it will use the version of Rails in the
vendor/rails directory. In other words, your application is no longer subject to the
ebb and flow of the system-wide installation of Rails. Instead, your app is locked to a specific
version of Rails.
This approach is quite handy, but it's not the end of the story. Freezing Rails in this way doesn't affect your local version control repository. You simply have a copy of all the Rails bits on your local machine. What version of Rails will be used when you deploy your application to another machine? Hrm.
Linking
The next step then is to make sure that a specific Rails version will travel along when you deploy your application onto a production machine.
One way to do that would be to freeze and then check all the files under the
vendor/rails directory into your version control repository. This would work, but it
makes upgrading (and downgrading) Rails versions a hassle. Your repository has one copy of the
Rails version and the official Rails repository has another. It would be more convenient to simply
create a link to the official Rails repository.
Linking up to the Rails mother ship turns out to be quite easy to do using Subversion externals. For example, to link to Edge Rails, you'd run the command
svn propset svn:externals \
"rails http://dev.rubyonrails.org/svn/rails/trunk" \
vendor
Now every time you run svn update you'll get the latest version of your application
code and any changes made to the version of Rails it's linked to. It's just like having
the Rails core members on your project!
Living on the edge can be a thrill while you're developing the app, but it's wise to bind your application to a version of Rails that's a bit less volatile before going into production. To do that, you'd just update the external link to a stable version:
svn propset svn:externals \
"rails http://dev.rubyonrails.org/svn/rails/tags/rel_1-2-1" \
vendor
svn update
Then of course you'd run your tests locally and all that good stuff before actually deploying it.
With the link in place, you don't have to do anything special after deploying the app. You run
cap deploy and it makes sure to check out your code onto the remote machine(s). And
because vendor/rails has an external link to a Rails version, that version will get
checked out as part of the automated process. So far so good!
Gratuitous Reflection Moment
This is the way I started doing deployments, but recently I made a switch to make things a bit more
efficient. See, we run cap deploy a lot. Sometimes it's to push new
official features to a production server, but more frequently it's to push small changes during
development to our private look-at-me server. It's the server that hosts our Edge application for
everyone on the team to poke and prod.
On a good development day I might cap deploy to the private server a dozen or more
times, while on a bad development day I might cap rollback it just as many times. And
every time anyone deploys, the external link triggers a trip out to the Rails Subversion repository
to suck down all those bits. That doesn't seem very responsible.
A More Efficient Approach
After asking around to see what other folks are doing, my eyes were opened to a few alternatives which I'll try to distill here.
First, as effortless as it is to have an external link to Rails and automatically get updates when
I run svn update on my app, I've never really felt that comfortable with it. The
majority of the time I run that command to update my code, and getting Rails updates is usually an
unexpected side effect. Perhaps I just don't belong on Edge Rails, but I'd prefer to have a
two-step process: Update my code, and then if everything shakes out go ahead and update Rails. That
way I'm not potentially fighting compounded problems.
Second, I have several apps all running the same Rails version, and I'd like to share that version. Yeah, disk space is cheap, but it seems silly to constantly be fetching stuff over the external link.
So the first change I've made is to stop using an external link for Rails. Instead, I check out a
version of Rails to a common directory and symlink it to vendor/rails in each of my
Rails applications. Here's how I do that, using Rails 1.2 as an example:
svn co http://dev.rubyonrails.org/svn/rails/tags/rel_1-2-1 \
~/work/rails/rel_1-2-1
cd my_rails_app
ln -s ~/work/rails/rel_1-2-1 vendor/rails
That covers managing Rails versions on my local machine. When it comes to deployment, I do
something similar: Check out a version of Rails to a common directory on the deployment server and
symlink it to vendor/rails of the deployed application.
Here's the simplest Capistrano task I could think of for doing that, which defaults to using Rails version 1.2:
desc "Deploy a shared Rails version"
task :deploy_rails do
ENV['RAILS_TAG'] ||= 'rel_1-2-1'
checkout_path = "#{shared_path}/rails"
symlink_path = "#{release_path}/vendor/rails"
run <<-CMD
if [ ! -d #{checkout_path} ];
then
echo "Checking out Rails #{ENV['RAILS_TAG']}...";
svn checkout --quiet http://dev.rubyonrails.org/svn/rails/tags/#{ENV['RAILS_TAG']} \
#{checkout_path};
fi
CMD
puts 'Linking Rails...'
run "rm -rf #{symlink_path}"
run "ln -nfs #{checkout_path} #{symlink_path}"
end
If the shared/rails directory doesn't exist, then the specified version of Rails is
checked out to that directory. Then a symlink is made between shared/rails and the
vendor/rails directory of the deployed application. The upshot is the Rails Subversion
repository is only accessed once to create a shared directory full of Rails bits.
The deploy_rails task then needs to be added to an after_update_code task
in the same Capistrano recipe file:
task :after_update_code, :roles => :app do deploy_rails end
Running cap deploy now will make sure the symlink is formed between each deployed
application release and the shared Rails version.
This approach gets the job done without fuss, and I like to start with simple stuff like this to
get something working fast and learn from it. But it's a bit messy, specifically because it needs
to check for the existence of the directory on the remote machine. To do that I had to resort to
some old-school shell syntax inside of the run section, which runs those commands on
the remote machine. I'm already missing Ruby syntax.
As well, it would be really nice if I could easily upgrade and downgrade the version of Rails used by the production app. Thankfully, Rick Olson has already cracked this nut.
The Cadillac Approach
After whipping up that Capistrano task, I appreciated Rick's solution all the more. It has the same basic underpinnings, but with a lot more cushion. Sit back and enjoy the smooth ride.
1. Snag This Code
Add this
code (a Rake task and a helper method) to your lib/tasks/common.rake file, for
example. Then make sure to check that file in to your SVN repository.
The Rake task uses a common Rails shared path to hold Rails revisions. Here's what the Rails shared path directory structure looks like:
shared/rails shared/rails/trunk shared/rails/rev_5989 shared/rails/rev_5990
It simply runs svn update on the shared/trunk directory to update it to a
given Rails revision, then exports it to a separate revision directory. It then symlinks this
exported Rails revision directory to the vendor/rails directory of the current release
of your application.
The next time the app is deployed using that Rails revision, it only needs to symlink the revision (no SVN access). Each release is bound to its own version of Rails, and you can roll back to older version of Rails if need be.
2. Hook It
Add the following after_update_code Capistrano task to your
config/deploy.rb file:
set :rails_version, 5990 unless variables[:rails_version]
task :after_update_code, :roles => :app do
run <<-CMD
cd #{release_path} &&
rake deploy_edge REVISION=#{rails_version}
CMD
end
This task simply runs the deploy_edge Rake task that you added to your
lib/tasks/common.rake file after Capistrano has checked out your application code.
Remember that all this happens on the remote machine!
The rails_version variable is not a standard Capistrano variable, so make sure to set
it to the desired Rails revision number. In this example I'm targetting Rails 1.2.1 which has a
revision number of 5990.
By default, the shared Rails version will end up in the shared directory that
Capistrano creates on the remote machine. But you can override this to put Rails anywhere on the
remote machine, which means you can share it across apps. To do that, change the call to the Rake
task to include setting the RAILS_PATH environment variable:
rake deploy_edge REVISION=#{rails_version} \
RAILS_PATH=/path/to/shared/rails
3. Deploy At Will
Now we're back to that single-command deployment step that we've all grown to love.
cap deploy
Except this time there's a bit more oomph! as it also sorts out the Rails version on the remote machine.
To upgrade or downgrade Rails versions, override the default value of the
rails_version variable on the command line:
cap -S rails_version=5991 deploy
Ah... now that feels a lot better!
The End
I haven't done anything new here. I didn't even plan (or have the time) to write this. I just sorta felt some pinching every time I deployed an app, and figured I'd listen to it and try to learn something new. What I found through Rick's help was something I felt deserved to bask in a bit more glow. It's certainly working really well for me so far. If you have even better mojo, please blog it.
Thanks to Rick Olson and Jamis Buck for the help and ideas!