Piotr Murach
Posted on December 30, 2019
In the "Working with Capistrano: Tasks, Roles, and Variables" article, we explored Capistrano's fundamental concepts. We wrote two useful tasks. One for logging into a server and another for accessing Rails console in a production system. This time around, we're going to write a more involved script composed of many tasks. By themselves, these tasks won't accomplish too much. But the sum of them all will help us automate the installation and upgrading of Ruby throughout the lifetime of our application.
Not so long ago, every deployment server that I worked with had only one version of Ruby installed. This system-wide installation forced all Rails applications to use a very specific version of Ruby, for example, 2.1.5. You were not only stuck with a Ruby version but also married to a particular Rails release that had a minimum Ruby requirement. It doesn't take much to realise the shortcomings of this approach. Any Ruby upgrade requires a new production server setup and a costly migration effort. Who has the time and money for this? From a reliability point of view, Ruby becomes a single point of failure. If Ruby were to become corrupted in some way then this would affect all the web applications on the same server.
The way to ease this pain is to use a Ruby version manager. A tool that will allow many different versions of Ruby coexist on a single server. What follows, each Rails application can have its own Ruby environment and be updated independently. Developers can easily change Ruby during the lifetime of their application without affecting other projects - a nice bonus.
There are many great tools such as RVM or chruby that can help you manage Rubies. But, rbenv
is my preferred choice. It strikes a perfect balance between features and simplicity. The project is also maintained by Ruby core team members and that's what I will demonstrate in this article. While we explore rbenv, we will lift the veil and examine more Capistrano techniques to add to your toolbox.
In the code samples that follow, we'll install files on a server in some places and read them back from others. I'll assume a Unix like operating system such as Linux since that's what I'm most familiar with. You may need to adjust a few bits according to your operating system of choice.
With that out of the way, let's dive into rbenv
installation.
Where to Install rbenv?
Before we decide where to install rbenv, we will want to name and organise our new task first. A great feature that Capistrano inherits from Rake build tool is the namespace method. It gives us a way to logically group tasks under one name. A side benefit is that we can reuse similar task names that otherwise could conflict with the built-in ones. I tend to group the tasks based on the overall function that ties them together. The rbenv as a namespace seems to fit well. We'll go ahead and inside lib/capistrano/tasks directory create a file called rbenv.rake with the :rbenv
namespace:
# lib/capistrano/tasks/rbenv.rake
namespace :rbenv do
...
end
To install rbenv, we need to decide where we want it installed first. There are two primary ways to install rbenv: system-wide and per user account. What's the difference? The system-wide installation means that a given Ruby version is installed only once per server in a central location. For example, on a Unix system that would be /usr/local/rbenv. Conversely, user account installation restricts a given Ruby to locations accessible to that particular user. Usually, this is a hidden folder inside the user's home directory, for example, $HOME/.rbenv. Choosing the latter may lead to having similar Ruby versions exist on the same server. It is a balancing act.
As we discussed before, the system-wide installation suffers from a single point of failure issue. Because of this, by default, we're going to install rbenv for each deployment user account. But we will expose :rbenv_path
variable to allow configuration of a custom path. To do so, we will create a helper method rbenv_path that will hold a rbenv installation path, by default, set to the user's home directory:
def rbenv_path
fetch(:rbenv_path, "$HOME/.rbenv")
end
The fetch method accepts as a second argument, a default value that will be used when no value is set in your script. There is another way we could do this by using a block.
def rbenv_path
fetch(:rbenv_path) { "$HOME/.rbenv" }
end
The difference is that the default value provided via the block will be lazy evaluated. This means that the evaluation of default value will only happen when the rbenv_path
method is first used. This comes handy in a situation when you have, for example, a dynamic value that depends on the evaluation of other methods. We are going to stick with the second argument option here.
One aside: when working with the filesystem paths, it's better to represent them as data types rather than strings. What does this mean? A string is a poor representation for a system path. It doesn't give you any extra information, for example, whether a path is relative or absolute. Ruby's standard library includes Pathname that can wrap path string representation. Unlike string, the pathname object can tell us information about the path it represents and enhance our path operations. We can build paths in a system-independent way. Joining two paths is as easy as adding them together. The pathname object will automatically use the correct file path separator. If you want to learn more, I recommend Paths aren't strings article.
Armed with this knowledge, we change rbenv_path
to return a pathname object instead:
# lib/capistrano/tasks/rbenv.rake
require "pathname"
namespace :rbenv do
def rbenv_path
Pathname.new fetch(:rbenv_path, "$HOME/.rbenv")
end
end
Now, we need to find a way to tell Capistrano to use the rbenv_path
method to find rbenv executable and make it available for other tasks to use.
The PATH Variable
When deploying applications, Capistrano uses what is called a non-login shell. In other words, Capistrano doesn't assume anything about the underlying system apart from establishing an SSH session. There is no visual terminal attached to this session. None of the shell configuration files, like a Bash profile file, are loaded. This is very much desirable behaviour that makes our deployments safe and repeatable. But it also means that we cannot rely on any configuration environment variables to be preloaded and available for our scripts.
Specifically, we cannot rely on the system to tell our scripts the location of rbenv binary installation. How can we work around this? We need to roll up our sleeves and show Capistrano where rbenv is installed ourselves. The way we can do this is by modifying the $PATH environment variable. This variable holds a list of directories delimited by a colon that the Unix shell uses to search for executable files when running commands. To check the value of your path, you can run the following in your shell:
$ echo $PATH
Your path may look something like this:
/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
This begs the question...how can we instruct Capistrano to change what locations does the $PATH variable point to? Remember, we're trying to make rbenv available for the non-login shell.
Mapping Binaries with SSHKit
The heart of Capistrano - the sshkit Ruby gem - is a toolkit for working with servers via SSH. The sshkit is responsible for doing the heavy lifting in terms of defining and executing commands on a server. Besides, it provides access to the current SSH session configuration with the SSHKit.config
method. In particular, the config
exposes the default_env
method that, by default, returns an empty hash. When a command gets executed, all the keys and their values are converted into the corresponding environment variables. Bear in mind that only the keys provided as symbols will change to an uppercase name:
SSHKit.config.default_env[:path] = "$HOME/.rbenv/bin:$PATH"
This is equal to:
SSHKit.config.default_env["PATH"] = "$HOME/.rbenv/bin:$PATH"
Both examples will result in a command being prefixed with a path:
$ PATH="$HOME/.rbenv/bin:$PATH"
The default_env
is exactly the place to change the $PATH
variable to include the location of rbenv installation path. Specifically, we want to add two directories with executables:
$HOME/.rbenv/bin
$HOME/.rbenv/shims
Now, we're ready to get our hands dirty and make Capistrano locate rbenv. We begin by adding a task called map_bins that will make common Ruby binaries use rbenv executable - but we're getting ahead of ourselves. Let's change the $PATH variable first.
# lib/capistrano/tasks/rbenv.rake
require "pathname"
namespace :rbenv do
desc "Map Ruby binaries to use rbenv"
task :map_bins do
...
end
def rbenv_path
Pathname.new fetch(:rbenv_path, "$HOME/.rbenv")
end
end
With the task defined, we read the current value for the :path
key to preserve what other tasks may have already configured:
desc "Map Ruby binaries to use rbenv"
task :map_bins do
path = SSHKit.config.default_env[:path]
end
You may have noticed that we haven't used the on scope. The scope is only needed when we want to execute remote commands on a server or access the server's configuration. None of this is necessary for the task at hand.
Next, we alter the path with the location of rbenv directories. We use rbenv_path
helper to add our two rbenv locations. If the path isn't configured, we prepend the two directories directly to the $PATH variable. If the path is configured, we prepend on top of it:
desc "Map Ruby binaries to use rbenv"
task :map_bins do
path = SSHKit.config.default_env[:path]
path_env = "#{rbenv_path.join('shims'}:#{rbenv_path.join('bin')}:" +
(path.nil? ? "$PATH" : path)
end
Finally, we override the SSHKit default_env
hash with a new modified environment path:
desc "Map Ruby binaries to use rbenv"
task :map_bins do
path = SSHKit.config.default_env[:path]
path_env = "#{rbenv_path.join('shims'}:#{rbenv_path.join('bin')}:" +
(path.nil? ? "$PATH" : path)
SSHKit.config.default_env.merge!(path: path_env)
end
But, we're not done yet. If we're going to do anything useful a server like installing Rails, we need to have a way to run Ruby commands like gem
or bundle
. This is necessary so that we can later issue the following commands to bootstrap a Rails application:
$ gem install bundler
$ bundle install
Let's say we want to run "bundle install"
command but there are many Ruby versions installed already. Which version of Ruby would provide the bundle
executable? We need to somehow tell our shell which Ruby version to use. Again the $PATH
variable comes to the rescue. We can extend the $PATH
with the correct Ruby version before running our executable. For example, to use bundle executable from the Ruby 2.6.3 installation, we would run the following in the Unix shell:
$ PATH="$HOME/.rbenv/versions/2.6.3/bin:$PATH" bundle install
But, this command is long, ugly, and hard to automate. Thankfully, the rbenv provides rbenv exec command that can detect the currently loaded Ruby version and include it in our path for us. Since we have already configured Capistrano to find rbenv binary, we can use exec
to rewrite our previous command like this:
$ rbenv exec bundle install
Now we know how to write our commands by hand. But how do we tell Capistrano to prepend common Ruby executables like bundle
with the rbenv exec
command for us? Before we answer this question, it's helpful to understand how an execute method works first.
Running Remote Commands
The sshkit
provides an execute method for running any shell command on a server. This is a powerful method that behaves differently depending on the arguments passed in. A peek under the covers reveals a rather succinct and short implementation:
# lib/sshkit/backends/abstract.rb
def execute(*args)
options = args.extract_options!
create_command_and_execute(args, options).success?
end
The above snippet doesn't look like much but there is a whole machinery hidden behind the execute
method. Conceptually, the process of running any command is split into two parts. The first part takes the command and converts it into an SSHKit::Command
object. This object handles assembling a full, shell-ready command from various bits like prefixes, flags and arguments. The second part takes care of executing the command which can be carried away by any of the SSHKit::Backends
. This approach lets you create command once and run it using different backends on many servers. This is such a great design decision!
When run with a 'simple' string without any whitespace, the execute hands over the command first to SSHKit::CommandMap
. Internally, the CommandMap
holds a hash of commands, empty by default, that allow additional configuration:
# lib/sshkit/command_map.rb
module SSHKit
class CommandMap
def initialize(value = nil)
@map = CommandHash.new(value || defaults)
end
end
end
By default, if there are no custom prefixes specified, a command is prefixed with "/usr/bin/env". Except for if
, test
, time
and exec
, commands that receive no prefixes. For example, if we were to check Ruby version using ruby executable as the first argument:
execute :ruby, "--version"
This would result in the default lookup and the following output:
/usr/bin/env ruby --version
If, on the other hand, we were to provide a command as a raw string with whitespace:
execute "ruby --version"
It would be executed as-is without any prefixing, shell escaping or directory changes. Basically no involvement from Capistrano or SSHKit
whatsoever:
ruby --version
This often tricks newcomers to Capistrano who scratch their head in puzzlement as to why a command fails to run on a server. So consider yourself warned.
Prefixing Commands
To change commands before they are run, we can use the command_map method exposed by the SSHKit
configuration object. We can then take the CommandMap
and chain it together with the prefix method to add new prefixes. Like CommandMap
, the returned PrefixProvider
stores a hash of command to prefix mappings. By default, any key lookup for non-existing command returns an empty array. Knowing this, we can override a default ruby command prefix:
SSHKit.config.command_map.prefix[:ruby] << "rbenv exec"
Then run our ruby command again:
execute :ruby, "--version"
But this time, we get a very different result:
rbenv exec ruby --version
That was a long but necessary diversion from the map_bins
task implementation. Now, we know how to prefix common executables like bundle
, gem
, or rake
with rbenv exec
. First, we will add rbenv_map_bins
method to allow for the configuration of Ruby binaries defaulting them to the most common:
def rbenv_map_bins
fetch(:rbenv_map_bins, %w[bundle gem rails ruby rake])
end
Armed with our knowledge of customising commands, we can prefix each binary and ensure we don't change any existing prefixes:
rbenv_map_bins.each do |exe|
SSHKit.config.command_map.prefix[exe.to_sym].unshift("rbenv exec")
end
Having prefixed Ruby binaries, the complete task for mapping commands will look like this:
desc "Map Ruby binaries to use rbenv"
task :map_bins do
path = SSHKit.config.default_env[:path]
path_env = "#{rbenv_path.join('shims')}:#{rbenv_path.join('bin')}:" +
(path.nil? ? "$PATH" : path)
SSHKit.config.default_env.merge!(path: path_env)
rbenv_map_bins.each do |exe|
SSHKit.config.command_map.prefix[exe.to_sym].unshift("rbenv exec")
end
end
Now running "bundle install"
(and similar) commands will work because they will be executed with the correct Ruby version. We can apply our task by running:
$ cap [stage] rbenv:map_bins
What we have configured so far is for the non-login shell. But, if a user wants to log into a server, the rbenv executable won't be available and none of the Ruby binaries will work. Let's change this!
Modifying Shell Startup File
It would be annoying to force anyone who logs into a server to manually run rbenv setup instructions before they could run, for example, a bundle command.
So what's the solution?
When a user logs into an operating system, this triggers loading of the shell startup files. A shell startup file allows for execution of arbitrary shell scripts, setting environment variables and so on. Sounds like just what we need. We could modify the shell startup file to set rbenv paths for us. This would make Ruby and any related executables available inside the logged-in user shell.
Many Linux distributions like Debian, typically, use a Bash shell as their default shell. When started as an interactive shell, Bash reads a startup file called bash_profile
. The following Bash script is all we need to insert into a bash startup file to load rbenv automatically:
if [ -d ~/.rbenv ]; then
export PATH="$HOME/.rbenv/bin:$PATH"
eval "$(rbenv init -)"
fi
This snippet of code first checks that rbenv directory exists. When it does, it adds rbenv binary to the $PATH
variable and makes it available in the shell. It then runs the rbenv initialisation script.
Now, we have everything we need to create a task called modify_shell_file
that will, as you've probably guessed, modify a shell startup file. We use the on scope with all role to specify the servers affected by our changes:
# lib/capistrano/tasks/rbenv.rake
namespace :rbenv do
desc "Map Ruby binaries to use rbenv"
task :map_bins do
...
end
desc "Change shell startup file to setup rbenv"
task :modify_shell_file do
on release_roles(:all) do
end
end
end
Testing Remote Commands
What we don't want to do is to add rbenv initialisation script to a startup file more than once. If the script is already present, we want to skip adding it. We could use the execute
method to run this check but that wouldn't get us far. Why is that? The execute
method, though powerful, is used for running commands where we don't care about capturing the final output. We only care that a command succeeds and the script continues or it fails and terminates a task. But, in a situation when we need to decide if a command ran successfully or failed without stopping a task, Capistrano provides the test method. This method is equal to execute
with the difference that it doesn't raise an error for non zero exit code. There is even :raise_on_non_zero_exit
option you can use to get this behaviour with execute method:
execute ..., raise_on_non_zero_exit: false
To scan a file for matching content, we will use the grep command. It is a basic Unix utility that should already be installed on a server. If a match is found, the grep
will finish with a zero exit code - exactly what we need. To skip the task and prevent further execution, we call next. If this task is part of many, the script will continue with the next task. This is a common idiom used in Capistrano plugins. It's worth noting that break
or exit
would immediately terminate the entire script and raise an error.
So the check to see if a startup file contains rbenv initialisation script looks like this:
desc "Change shell startup file to setup rbenv"
task :modify_shell_file do
on release_roles(:all) do
next if test "grep -qs 'rbenv init' ~/.bash_profile"
end
end
We need to jump through some Unix shell hoops to programmatically insert a multiline string. When executing a multiline command, Capistrano replaces every newline character with a semicolon to essentially collapse a command into a single line. To prevent this and insert rbenv script line by line into the bash startup file, we're going to use the printf utility. If you used Ruby's printf method, the Unix printf
utility is going to be very familiar. They both use a format string followed by input arguments, the difference being shell uses whitespace to split inputs.
Let's append the rbenv initialisation script to the Bash profile file:
desc "Change shell startup file to setup rbenv"
task :modify_shell_file do
on release_roles(:all) do
next if test "grep -qs 'rbenv init' ~/.bash_profile"
execute %q{printf "%s\n %s\n %s\n%s" 'if [ -d ~/.rbenv ]; then' 'export PATH="$HOME/.rbenv/bin:$PATH"' 'eval "$(rbenv init -)"' 'fi' >> ~/.bash_profile}
end
end
Having configured rbenv for the login and non-login shell alike, we're ready to install rbenv itself. It was quite a bit of a setup but I promise it was worth it.
Installing Rbenv & Rbenv Build
At this point, we're not going to reinvent the wheel and instead use a rbenv installer. The rbenv installer includes a doctor script that will verify that rbenv is correctly configured. If it isn't then the installation script won't proceed any further and return non-zero exit code. This will stop the installation process in its tracks.
The benefit of using rbenv installer is that it will also install the rbenv-build tool for compiling and installing Ruby. Rbenv by default doesn't include any scripts for installing Ruby, it limits its functionality to only managing Ruby versions. However, in our case, we want to install Ruby as well.
Let's modify the rbenv.rake
file and add a new run_installer task:
# lib/capistrano/tasks/rbenv.rake
namespace :rbenv do
desc "Map Ruby binaries to use rbenv"
task :map_bins do
...
end
desc "Change shell startup file to setup rbenv"
task :modify_shell_file do
...
end
desc "Install rbenv and rbenv-build tools"
task :run_installer do
end
end
Similar to the task for modifying a shell startup file, we want to skip installing rbenv if it's already setup. To do so, we're going to use bash shell to check for rbenv directory existence:
desc "Install rbenv and rbenv-build tools"
task :run_installer do
on release_roles(:all) do
next if test("[ -d #{rbenv_path} ]")
end
end
We start by saving the rbenv installer location in a method rbenv_installer_url
:
def rbenv_installer_url
"https://github.com/rbenv/rbenv-installer/raw/master/bin/rbenv-installer"
end
One word of caution here. Before using a third-party script, it's best to read through and verify that a script doesn't contain any malicious code first. For extra measure, it's advisable to store such script on an internal network and only download it from there.
Then, installing rbenv is just a matter of downloading the installation script using the wget utility in quiet mode. Once the installation script is downloaded, we're going to pipe its content into the Bash shell to perform the installation process:
desc "Install rbenv and rbenv-build tools"
task :run_installer do
on release_roles(:all) do
next if test(" [ -d #{rbenv_path} ] ")
execute :wget, "-q", rbenv_installer_url, "-O-", "|", :bash
end
end
And with that we have successfully installed rbenv utility on our server. This way we have opened the doors for automating Ruby installation.
Summary
In this article, we wrote a few fairly complex tasks that gave us a much bigger taste of Capistrano capabilities. We modified environment variables, updated a shell startup file and installed rbenv utility. As we did all this, we explored more of the Unix shell concepts and learnt how rbenv works.
As a side benefit, this article demonstrated to us the extreme flexibility that Capistrano architecture gives us. It lets you provision the application infrastructure the way you need. You describe your requirements using task definitions and, viola! Not sure about you, but I'm impressed with Capistrano design. It embraces Ruby's dynamic nature and object model to provide you with an intuitive way of expressing deployment concepts.
Nice. We’re another level up on our path to Capistrano proficiency. In the next article, we will continue to explore how to combine all the tasks to install a brand-new Ruby.
This article was originally published on PiotrMurach.com.
Photo by Tincho Franco on Unsplash
Posted on December 30, 2019
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.