The RVM Ruby API - Setting Up A CI System For The SQL Server Adapter
A few weeks ago I started looking into the Ruby Version Manager (RVM) project to help me build a better testing setup for both my day job and the ActiveRecord SQL Server Adapter. In a previous article I covered details of how to get a development stack up and running for Rails with SQL Server using MacPort's. This article will cover some new additions to that goal, but first and primarily, I wanted to talk about the wonders of RVM and it's new ruby API.
So like any good agile software gardner tasked with building a continuous integration system, I wanted to do it in such a way that was completely automated using rake. My first cut at said rake task used ruby's Kernel.system to issue rvm
commands down to the shell. This failed horribly! Basically no matter how hard I tried, I could not hit the rvm shell function from ruby's system command. It kept using the rvm binary which can not change the local shell environment and hence do very little magic that RVM allows. Thankfully @wayneeseguin pointed me to the new RVM ruby API and this article on how to use it for passenger. I immediately started to switch my rvm rake tasks to use the new RVM API and was just floored with how well it did. Below is a copy of that rake task. Take a look over it and read below for details and how I have used this with the SQL Server stack.
MYPROJECT_RUBIES = {
'ruby-1.8.6-p388' => {:alias => 'myprj186', :odbc => '0.99991'},
'ruby-1.8.7-p299' => {:alias => 'myprj187', :odbc => '0.99991'},
'ruby-1.9.1-p378' => {:alias => 'myprj191', :odbc => '0.99991'},
'ruby-1.9.2-head' => {:alias => 'myprj192', :odbc => '0.99992pre3'},
'ree-1.8.7-2010.02' => {:alias => 'myprjree', :odbc => '0.99991'}
}
namespace :rvm do
task :setup do
unless @rvm_setup
rvm_lib_path = "#{`echo $rvm_path`.strip}/lib"
$LOAD_PATH.unshift(rvm_lib_path) unless $LOAD_PATH.include?(rvm_lib_path)
require 'rvm'
require 'tmpdir'
@rvm_setup = true
end
end
namespace :install do
task :all => [:setup,:rubies,:odbc,:gems]
task :rubies => :setup do
installed_rubies = RVM.list_strings
MYPROJECT_RUBIES.keys.each do |rubie|
if installed_rubies.include?(rubie)
puts "info: Rubie #{rubie} already installed."
else
with_my_environment_vars do
good_msg = "info: Rubie #{rubie} installed."
bad_msg = "Failed #{rubie} install! Check RVM logs here: #{RVM.path}/log/#{rubie}"
puts "info: Rubie #{rubie} installation inprogress. This could take awhile..."
RVM.install(rubie,rvm_install_options) ? puts(good_msg) : abort(bad_msg)
end
end
RVM.alias_create MYPROJECT_RUBIES[rubie][:alias], "#{rubie}@myproject"
end
end
task :odbc => :setup do
rvm_each_rubie do
odbc = "ruby-odbc-#{myproject_current_rubie_info[:odbc]}"
RVM.chdir(Dir.tmpdir) do
RVM.run "rm -rf #{odbc}*"
puts "info: RubyODBC downloading #{odbc}..."
RVM.run "curl -O http://www.ch-werner.de/rubyodbc/#{odbc}.tar.gz"
puts "info: RubyODBC extracting clean work directory..."
RVM.run "tar -xf #{odbc}.tar.gz"
RVM.chdir("#{odbc}/ext") do
puts "info: RubyODBC configuring..."
RVM.ruby 'extconf.rb', "--with-odbc-dir=#{rvm_odbc_dir}"
puts "info: RubyODBC make and installing for #{rvm_current_name}..."
RVM.run "make && make install"
end
end
end
end
task :gems => :setup do
puts "info: Installing our app gems."
rvm_each_rubie do
myproject_gem_specs.each { |spec| rvm_install_gem(spec) }
end
end
end
task :remove => :setup do
myproject_rubies.each { |rubie| RVM.remove(rubie) }
end
end
def myproject_rubies
MYPROJECT_RUBIES.keys.map{ |rubie| "#{rubie}@myproject" }
end
def myproject_current_rubie_info
MYPROJECT_RUBIES[rvm_current_rubie_name]
end
def myproject_gem_specs
[
['rails','2.3.8'],
['activerecord-sqlserver-adapter','2.3.8'],
['erubis','2.6.6'],
['haml','3.0.13'],
['mocha','0.9.8'],
]
end
def rvm_each_rubie
myproject_rubies.each do |rubie|
RVM.use(rubie)
yield
end
ensure
RVM.reset_current!
end
def rvm_current_rubie_name
rvm_current_name.sub('@myproject','')
end
def rvm_current_name
RVM.current.expanded_name
end
def rvm_gem_available?(spec)
gem, version = spec
RVM.ruby_eval("require 'rubygems' ; print Gem.available?('#{gem}','#{version}')").stdout == 'true'
end
def rvm_install_gem(spec)
gem, version = spec
if rvm_gem_available?(spec)
puts "info: Gem #{gem}-#{version} already installed in #{rvm_current_name}."
else
puts "info: Installing gem #{gem}-#{version} in #{rvm_current_name}..."
puts RVM.perform_set_operation(:gem,'install',gem,'-v',version).stdout
end
end
def for_macports?
`uname`.strip == 'Darwin' && `which port`.present?
end
def rvm_install_options
{}
end
def my_environment_vars
if for_macports?
{'CC' => '/usr/bin/gcc-4.2',
'CFLAGS' => '-O2 -arch x86_64',
'LDFLAGS' => '-L/opt/local/lib -arch x86_64',
'CPPFLAGS' => '-I/opt/local/include'}
else
{}
end
end
def rvm_odbc_dir
for_macports? ? '/opt/local' : '/usr/local'
end
def set_environment_vars(vars)
vars.each { |k,v| ENV[k] = v }
end
def with_my_environment_vars
my_vars = my_environment_vars
current_vars = my_vars.inject({}) { |cvars,kv| k,v = kv ; cvars[k] = ENV[k] ; cvars }
set_environment_vars(my_vars)
yield
ensure
set_environment_vars(current_vars)
end
RVM Rake Task Breakdown
This is the fun part - lets start from top to bottom. I'll focus only on the parts that are centric to why the RVM ruby API is so bad ass and the rake task in general. The following section is dedicated to RVM with the SQL Server Adapter stack. First, the :setup task, this is called before every other task. It simply sets up the load path so that the RVM API file can be required. That api file is located in your rvm repo path, typically in your ~/.rvm directory. Now every other task can use the RVM
module which implements method missing for many commands.
Past this my rvm namespace is broken up into three main install tasks. The primary concerns are rvm:install:rubies
and rvm:install:gems
each invoked by the rake task. Starting with the :rubies task, this iterates over a collection of ruby version strings first checking if RVM knows its installed then installing it otherwise.
In the :gems task, things get a little interesting. I am using a few methods (seen toward the bottom) that give this a nice little DSL of my own around RVM. The first is a block method called rvm_each_rubie
. This iterates over each of my project's ruby strings, tells RVM to use that ruby, hence dynamically switching to that ruby/gemset then yielding to the block. That means that each go around I will be in an completely different ruby/gem environment, each specific to my project using RVM gemsets. This allows the inner method for iterating over the required gems for my project and asking to install them with rvm_install_gem
. This method uses RVM.ruby_eval
to execute a string of ruby in the context of the current ruby version and gemset. In this case, finding out if a gem is installed already. If not, I use the RVM.perform_set_operation
to install the gem, again in current context. I use perform_set_operation so I can read the STDOUT back to the rake task so a user sees exactly what is going of for the gem install.
Past that there are a few other details. Most can be picked up by reading the helper methods toward the bottom. Using the RVM ruby API is a bit of a chore if you rely on documentation. Sure it has some, but nothing beats reading the code to see what is available to you. Remember you can find the API library by opening up the ~/.rvm/lib
directory. I'm sure you can also find help on the RVM IRC channel.
Though it is not specific to general RVM API goodness, the :odbc task does show some great interfaces that RVM exposes for doing standard file system directory changing and running commands to the system.
Notes On RVM With The SQL Server Adapter Stack
So you use the ActiveRecord SQLServerAdapter? That means you have some underlying components installed - namely FreeTDS, unixODBC, and RubyODBC right. If your like me and believe that MacPorts is the way to go and risking a Homebrew interleaved dependency with Apple's libraries is risky, this section is for you! So you have a MacPort base and you want to compile your RVM rubies in such a way that other dependencies such as Nokogiri and RubyODBC use your /opt/local installs. By default this does not happen because unless ruby was compiled the right way, it wont be able to allow built gems to know about your /opt/local directory. So this is what I came up with.
Take a look at the my_environment_vars
method. This works in-conjunction with the with_my_environment_vars
block method. Basically it temporarily set's MacPort specific environment variables before installing a ruby version via RVM. The ones shown are what I have found work best for my system. I think the most important are LDFLAGS=-L/opt/local/lib -arch x86_64
and CPPFLAGS=-I/opt/local/include
. Once ruby is built with those, it can easily reflect with standard 3rd party install methods that use RbConfig
. To date any gem that I have had to compile, most importantly RubyODBC, does so perfectly against my /opt/local ports. This includes MySQL, Nokogiri, everything! I love it. I totally encourage anyone to use RVM and to get to know it's great ruby API for automating all sorts of things.