Ruby Development Using Guix
Until recently, I was working on a Ruby on Rails monolith. In the four years that I worked on the project, we went through a few ways of building a local development environment:
- directly using Bundler on the host machine, then
- using Bundler inside a Docker image, then
- using Nix and Bundix, then
- back to using Bundler inside a Docker image.
All of these options were fine, but my package manager of choice is Guix. I really wanted a way for me to work with my Ruby on Rails project alongside the rest of my team, but using Guix to build my development environment.
Guix has a lot of Ruby packages, but it doesn't package all of RubyGems, nor does it package all the different versions of each Gem. This makes it difficult to use Guix to get precise dependencies for a given Ruby project.
To make things easier, I built Guix Ruby. A Guix channel that provides a guix ruby command which imports a Gemfile.lock and constructs Guix packages for all the listed dependencies.
Basic Usage
If we start with a simple Gemfile, like this:
source "https://rubygems.org"
gem "nokogiri"
Then we can run guix ruby --lock --init to generate a Gemfile.lock (from Bundler), and a corresponding Gemfile.lock.scm (from Guix Ruby). The resulting Gemfile.lock.scm looks like this[^lockfile]:
(use-modules
(guix-ruby gems)
(gnu packages ruby)
(guix build-system ruby)
(guix download)
(guix git-download)
(guix packages)
(ice-9 match))
(lambda* (#:key (ruby ruby) (groups '(default)) (gem-transformers %default-gem-transformers))
(define ruby--nokogiri
(gem
(transformers gem-transformers)
(name "ruby--nokogiri")
(version "1.19.0")
(propagated-inputs
(or
(match (%current-system) ("x86_64-linux" (append (list ruby--racc))))
(error "No supported system found for ~a@~a" "ruby--nokogiri" "1.19.0")))
(source
(or
(match
(%current-system)
("x86_64-linux"
(origin
(method url-fetch)
(uri (list "https://rubygems.org/gems/nokogiri-1.19.0-x86_64-linux-gnu.gem"))
(sha256 (base32 "135md1d9w7hkrc6dvs59wcqwxlpqc92y2k6490fh6q1xf5fbk0pl")))))
(error "No supported system found for ~a@~a" "ruby--nokogiri" "1.19.0")))
(arguments (list #:ruby ruby #:tests? #f))))
(define ruby--racc
(gem
(transformers gem-transformers)
(name "ruby--racc")
(version "1.8.1")
(propagated-inputs (append))
(source
(origin
(method url-fetch)
(uri (rubygems-uri "racc" version))
(sha256 (base32 "0byn0c9nkahsl93y9ln5bysq4j31q8xkf2ws42swighxd4lnjzsa"))))
(arguments (list #:ruby ruby #:tests? #f))))
(append (if (member 'default groups) (append (list ruby--nokogiri)) (list))))
You aren't expected to read or modify this file (guix ruby will write over it whenever it runs), but it defines a function that that returns Guix packages. You can use these packages to populate your manifest/package dependencies.
The --init option tells guix ruby to generate a barebones manifest for your project, which looks something like this:
(use-modules (guix profiles) (gnu packages))
;; Define your desired Ruby version here.
(define ruby (specification->package "ruby"))
(packages->manifest
(cons* ruby ; add your Ruby version to your profile
((load "Gemfile.lock.scm")
;; - the Ruby version to build packages with
#:ruby ruby
;; - the bundler groups you want included in your environment
#:groups '(default development test))))
Here you can see that Gemfile.lock.scm is being loaded, and the resulting value is called as a function. This function returns the Guix packages that represent your Bundler dependencies (filtered by the provided groups).
Once you have this in place, calling guix shell --manifest=manifest.scm -- irb will give you a Ruby shell with your declared dependencies available:
irb(main):001> require 'nokogiri'
=> true
One minor note: Guix caches the results of guix shell for a while, but it only invalidates the cache when your manifest/package file changes. If you run guix ruby and it changes Gemfile.lock.scm, you may need to run guix shell with the --rebuild-cache flag.
Gem Transformers
This automatic importing of Gems is great, but it has one big problem - some Gems need more setup. For example, the sqlite3 Gem. It has a dependency on SQLite in order to compile its native extensions. Some Gems (such as nokogiri above) are also packaged with pre-built binaries, which don't run directly on a Guix system (because they can't find the dynamic linker).
To solve this problem we need to use Gem Transformers. These functions run as the Gems are loaded in Gemfile.lock.scm, and get a chance to modify the resulting Guix packages.
There is a standard set of Gem Transformers in the Guix Ruby channel. For example, the transformer for sqlite3 passes the --enable-system-libraries flag when building the Gem, and makes the sqlite Guix package available to the build.
(gem-transformer
(matcher 'sqlite3)
(action (lambda (gem-package)
(package
(inherit gem-package)
(arguments
(substitute-keyword-arguments (package-arguments gem-package)
((#:gem-flags flags #~())
#~(list #$@flags "--" "--enable-system-libraries"))))
(inputs (cons* (specification->package "sqlite")
(package-inputs gem-package)))))))
You can add your own Gem Transformers (or replace the default ones) by passing a #:gem-transformers keyword argument alongside #:ruby and #:groups. The default list of Gem Transformers is %default-gem-transformers.
Not every Gem needs a transformer to work, so you shouldn't need to write too many to get things working. If you run into a case where you have to write a transformer, let me know on the mailing list and I can add your transformer into the default set.
[^lockfile]: Bundler resolves the declared dependencies when it generates Gemfile.lock, so the versions/hashes/systems might be different if you run this yourself.