Using Bundler within Bundler without going crazy
We recently completed a project where we were provisioning new Spree apps on demand for users. This meant having to create – and boot – a new Rails app from within an existing app – which was not without its problems.
Rails templates
Fortunately, creating the skeleton for the Spree stores was relatively easy, thanks to Rails app templates (the -m
option you give to rails new
). We were able to use the knowledge gained from writing our own team template to set up a basic Spree store, with the necessary overrides and initialiser files we needed.
To ensure that each store always had the same set of gem versions, we used our template to create a new Rails app, then copied across the generated Gemfile.lock
into our template, meaning that bundle install
in the newly-created Rails app would skip the dependency resolution from the Gemfile
and use the versions specified in the Gemfile.lock
. This meant we could rely on Rubygems (or a local cache) for our gems, rather than having to fork various gems on Github and use the forks as our gem source (via the github:
option in the Gemfile
). Updates to gems would be as simple as creating a new Gemfile.lock
, copying it across to the stores, and re-bundling.
So far, so good.
Running rails new
from a Rails app
To actually kick off the store provisioning, we wrote a service object that wrapped the setup of the rails new
command to be run, so that we could set the app name, database credentials, directory etc. easily, with the service object concerned with how we actually passed these to the new app (we used environment variables).
We then use Kernel#spawn
– with its incredible array of options you can pass – to spawn a separate process to run the rails new
command, with the appropriate logging of STDOUT
/STDERR
and the right ENV
variables.
Since we don’t want to wait for the installation process (as it would slow down our webserver process for everyone else, leaving us unable to service other incoming requests), we call Process.detach
to indicate that we’re not interested in knowing when the process finishes (the template notifies the user itself – via email – when the process completes successfully).
This worked fine, until we reached bundle install
, when we enter…
Bundleception
Turns out Bundler – in a Rails app – works part of its magic through altering several environment variables, which are used by Ruby to locate the correct version of gems to load, as well as specifying which Gemfile
to use. Kernel#spawn
will, by default, pass all these environment variables on to your new process, which means that our newly created Rails app will then be trying to use the Gemfile
of our master app, and will promptly blow up as it gets gem version conflicts.
We initially tried hand-crafting our own environment variables, unsetting the ones Bundler introduces and resetting the ones it rewrites. This was a major headache. However, after a consultation of the Bundler documentation, we found:
Bundler.with_clean_env
Fortunately, the Bundler team have a nice way out of our predicament. The Bundler.with_clean_env
method takes a block, in which all of Bundler’s alterations of the ENV are undone, then restored outside of the block. We could thus turn this:
pid = Kernel.spawn("rails new #{store_name}")
Process.detach(pid)
into this:
Bundler.with_clean_env do
pid = Kernel.spawn("rails new #{store_name}")
Process.detach(pid)
end
and our new Rails app will have no knowledge of the app that created it. Success!
Photo by Alex Eylar on Flickr