Working with Capistrano: Tasks, Roles, and Variables

piotrmurach

Piotr Murach

Posted on November 17, 2019

Working with Capistrano: Tasks, Roles, and Variables

Capistrano is a great tool for automating the deployment and maintenance of web applications. I use it to deploy Ruby on Rails and other Rack-based applications. It takes care of all the tedious bits such as installing dependencies, compiling assets or migrating database schemas. The alternative would be to manually log into a server via SSH and run all the necessary commands ourselves. This would open a path to terrible sins and consume a lot of time and patience. Thankfully we don’t have to.

The modular design of Capistrano means that there are plenty of plugins and scripts available. They will make even the thorniest deployment needs possible. Even if you don’t find what you want off the shelf, Capistrano flexible nature will help you write easily your own tasks. You will be able to tell Capistrano exactly how you want your deployment to look like. It will then prepare the server and continue to help you maintain updates to your application.

In this series of articles, my goal is to provide core concepts and explore more advanced features of the Capistrano deployment tool. To do so, we will dive into different real-life examples of solutions to common problems maintaining web applications in Ruby. These examples will teach you the ins and outs of how to write Capistrano scripts to get the job done quickly. You should also develop a better sense of the internals of Capistrano.

All the code samples in this article assume Capistrano 3. At the time of this writing, this means installing Capistrano 3.11.2. Please read the official guides on how to set up Capistrano in your project.

With that said, let's start!

Capistrano Tasks

At its core, Capistrano uses tasks provided by the Rake build tool to describe various actions that will update and deploy your application. It then supercharges these tasks with its own intuitive DSL that helps execute commands in Unix shell on a local machine or a remote server.

For a long time, I used to manually log into different servers over an SSH connection to perform one-off updates. This, in turn, meant that I needed to know and type in the shell the deployment user and server name each time I wanted to, for example, access a staging server. This is tedious and error-prone!

Surely, there is a better way?

Ideally, I would like to have a consistent way to login to any server based on the deployment environment without needing to know the details. The equivalent of saying "I want to login into the staging server". In Capistrano, this would translate into executing the following command:

$ cap staging login

Let's see how we could implement this command as a task.

By default, Capistrano will automatically load files from lib/capistrano/tasks directory. We'll go ahead and create login.rake file inside that directory. Notice the *.rake extension which is typically used to name Ruby files that contain Rake tasks. Rake provides a method called desc that can be used to add a task description. The description will be displayed when the user runs cap - -tasks to see all available tasks. Rake also exposes a method called task that can be used to express the behaviour of a task. Immediately after the description, we use the task block to register a new, albeit empty for now, :login task:

# lib/capistrano/tasks/login.rake

desc "Login into a server based on the deployment stage"
task :login do
  ...
end

So far the login task will do nothing. To be able to log in, we need to gather information about our application server. This requires us to talk about the concept of roles.

Capistrano Roles

To give us a fine-grained control over which tasks are run on which servers, Capistrano uses a concept of a role. For example, you may want to apply a task only to a database server and skip it when deploying to a web server. The role acts as a filter that tells Capistrano to group tasks with the same role together before executing them on a server matching a specific role.

There are three common roles used in Capistrano tasks:

  • The app role is used by any task that runs on an application server, a server that generates dynamic content. In Rails this is puma server. Capistrano's built-in tasks deploy:check, deploy:publishing or deploy:finished are all run in this role.
  • The db role is used by tasks that execute against a database server. For example, the capistrano-rails plugin provides the deploy:migrate task for migrating the Rails database schema.
  • The web role is used by tasks that deal with web servers that serve static content, think Nginx here. A community created capistrano3-nginx plugin uses this role in all of its tasks like nginx:start, nginx:reload or nginx:site:disable.

We can also define custom roles of our own. For example, a role called redis that will only run tasks related to Redis database instance:

role :redis, "redis-server"

If we want our task to match any server and we don't care what role a server has, we can use the all role. Capistrano's built-in tasks use this role to stay flexible.

In our case, we will be deploying to a single machine that hosts the application, web and database servers. To apply all three roles app, db and web to our server, we use a shorthand definition via the server method:

# config/deploy/staging.rb

server "staging-server.example.com", roles: %w[app db web], primary: true

If you examine the above server definition, you'll notice a property called primary. This property will let Capistrano know the order of precedence when running tasks. The tasks with roles associated with the primary server will run first. This becomes especially useful when your application is split between many hosts. If that's the case, then we could expand our definition to focus on roles instead:

# config/deploy/staging.rb

role :app, "app-server.example.com"
role :db, "db-server.example.com", primary: true
role :web, "static-server.exmaple.com"

Moving ahead, we’ll see how to apply our defined roles in a task.

Getting Host Configuration

To access our server configuration, we will use the roles helper method. This method accepts one or more role definitions and returns a list of all matching host instances. We specify an :app role to get, in our case, a list with only a single server configuration instance of Capistrano::Configuration::Server.

Among many innovations, Capistrano 3 introduces more modular architecture. It offloads management of SSH session to another dependency, a Ruby gem called sshkit. The SSHKit via its DSL provides an on method that allows us to run commands described in a block scope on many servers. As an argument, the on takes an array of host configuration objects and then uses SSHKit::Coordinator to run commands in parallel on each host:

# lib/sshkit/dsl.rb

module SSHKit
  module DSL
    def on(hosts, options={}, &block)
      Coordinator.new(hosts).each(options, &block)
    end
  end
  ...
end

If you have more than one host configured, it's probably better to instruct Capistrano to login to each server in a sequence:

on roles(:app), in: :sequence do |server|
  ...
end

Inside the on scope we gain access to the host instance that contains information about our server. Essentially, we're saying "on this server do the following things":

# lib/capistrano/tasks/login.rake

desc "Login into a server based on the deployment stage"
task :login do
  on roles(:app) do |server|
    ...
  end
end

Capistrano Variables

To ssh into a server we need to know the user name, the server name and the path to log the user into. For your configuration needs, Capistrano provides a method called set that allows you to configure variable like settings globally or for specific tasks and make them available to other parts of your script. For example, we can set the :user and :deploy_to variables in the following way:

# config/deploy/staging.rb

set :user, "deploy-user"
set :deploy_to, "/path/to/deploy/directory"

To help us read our configuration variables, Capistrano makes available a method called fetch. For example, we can use it to get the user name and deployment path:

# lib/capistrano/tasks/login.rake

desc "Login into a server based on the deployment stage"
task :login do
  on roles(:app) do |server|
    user = fetch(:user)
    path = fetch(:deploy_to)
    ...
  end
end

Now that we know the user name and path to login into, we can construct a URI that we will use as an argument to ssh command utility. We build the URI string by concatenating the user name, server name and port number. The code will handle the cases where the user name or port are missing:

# lib/capistrano/tasks/login.rake

desc "Login into a server based on the deployment stage"
task :login do
  on roles(:app) do |server|
    user = fetch(:user)
    path = fetch(:deploy_to)

    uri = [
      user,
      user && ‘@’,
      server.hostname,
      server.port && :,
      server.port
    ].compact.join
  end
end

The SSH Command

We're now ready to run the ssh command with the URI and deployment path. Before we do that though, there are a few interesting things to note here. In particular, the -t flag which instructs ssh utility to run in teletype mode. What is teletype mode? By default, Capistrano deploys via SSH connection without attaching a visual terminal. There is no need to show beautiful interfaces as nobody can see anything anyway. But, in our case we want the user to have access to all the shell capabilities.

After the -t flag we specify the URI location of the server. Then in quotes enclose shell commands to run once logged in. We string together two shell commands. First, we navigate to the website root directory and then execute a $SHELL variable. The $SHELL is an environment variable that holds the location for the default shell, usually Bash. Why do we need to start the shell? Without executing the shell, the ssh command would finish and log us out. But we want to remain logged in, that’s the whole point of this task! In case of bash, we use -l flag to invoke it as if a user logged in. We do this to preload all the configurations in hidden files such as the Bash profile.

The final command string will look like this:

"ssh -t #{uri} 'cd #{path}/current && exec $SHELL -l'"

We use Ruby's exec to run our ssh command locally:

exec("ssh -t #{uri} 'cd #{path}/current && exec $SHELL -l'")

Putting everything together, the entire task looks like this:

desc "Login into a server based on the deployment stage"
task :login do
  on roles(:app) do |server|
    user = fetch(:user)
    path = fetch(:deploy_to)

    uri = [
      user,
      user && '@',
      server.hostname,
      server.port && ":",
      server.port
    ].compact.join

    exec("ssh -t #{uri} 'cd #{path}/current && exec $SHELL -l'")
  end
end

We talked about logging quickly into a server from our machine. But this is not the only way to enhance our everyday workflow with Capistrano.

Remote Rails Console

Often I need to jump straight into Rails console on a server and run some queries against a live database. I could use the login task and then once logged in, type in a full command to open the Rails console. But this would get annoying quickly. How about we script Capistrano to log us straight into a production Rails console instead? The way we would like to do this is by running the following command:

$ cap production console

To implement the above, inside the same login.rake file, we write a bare-bones :console task with a description:

# lib/capistrano/tasks/login.rake

desc "Open Rails console on a server based on the deployment stage"
task :console do
    ...
end

Like the login task, to access our server, we need to know the user name and deployment path. Besides, to open the Rails console we also need to know the environment that the Rails application runs in. But, we can glean this from the :rails_env configuration variable which is configured by the capistrano-rails gem:

# lib/capistrano/tasks/login.rake

desc "Open Rails console on a server based on the deployment stage"
task :console do
  on roles(:app) do |server|
    env = fetch(:rails_env)
    user = fetch(:user)
    path = fetch(:deploy_to)
    ...
  end
end

Finding Rails Executable

Before we can open Rails console via SSH, we need to deal with one extra issue. Because we don't have access to a pseudo-terminal, none of the user profile configuration files will be loaded. We need to somehow tell Capistrano where to find the rails executable and in turn where to find Ruby installation. For me, the preferred way is to use the rbenv utility to manage Ruby installation and the Bundler for gems installation. If you use another Ruby manager please adjust your paths appropriately.

For the rbenv path, we use the deployment user local installation location of $HOME/.rbenv. Rbenv has a concept of shims, which are lightweight executables that map installed Ruby commands such as gem, irb or rails to rbenv exec command. The rbenv exec will then prepend a specific command with a correct Ruby installation path. To execute the rails command, we need to use bundle shim which will ensure all our dependencies are loaded. We can access the bundle executable in $HOME/.rbenv/shims directory. As a last step, we inform the rails console command about the environment we're using via the -e flag. The full command to open rails console looks like this:

console_cmd = "$HOME/.rbenv/shims/bundle exec rails console -e #{env}"

With the final piece of the puzzle assembled, we can ssh into the current Rails application directory and open the Rails console:

"ssh -t #{uri} 'cd #{path}/current && #{console_cmd}'"

Now let's tie everything together in the final script:

desc "Open Rails console on a server based on the deployment stage"
task :console do
  on roles(:app) do |server|
    env  = fetch(:rails_env)
    user = fetch(:user)
    path = fetch(:deploy_to)

    uri = [
      user,
      user && '@',
      server.hostname,
      server.port && ":",
      server.port
    ].compact.join

    console_cmd = "$HOME/.rbenv/shims/bundle exec rails console -e #{env}"

    exec("ssh -t #{uri} 'cd #{path}/current && #{console_cmd}'")
  end
end

Removing Duplication

Now all you eagle-eyed readers probably noticed that we have a bit of repetitive code between the tasks. Let’s tackle it so we can finish strong and improve maintainability. One thing I like to do before I remove any clutter is to repeat code verbatim, like I did in the second task. This is so that I can visually highlight the parts that are the same. We can see that apart from the rails environment variable and ssh command to run, the tasks are identical.

In the spirit of removing duplication, we will move the common part of setup and running ssh command into its own method called run_ssh_with. The method will accept as arguments the server configuration and command to run:

# lib/capistrano/tasks/login.rake

def run_ssh_with(server, cmd)
  user = fetch(:user)
  path = fetch(:deploy_to)

  uri = [
    user,
    user && "@",
    server.hostname,
    server.port && ":",
    server.port
  ].compact.join

  exec("ssh -t #{uri} 'cd #{path}/current && #{cmd}'")
end

Thanks to the run_ssh_with, we can simplify both tasks into:

# lib/capistrano/tasks/login.rake

desc "Login into a server based on the deployment stage"
task :login do
  on roles(:app) do |server|
    run_ssh_with(server, "exec $SHELL -l")
  end
end

desc "Open console on a remote server based on the deployment stage"
task :console do
  on roles(:app) do |server|
    env  = fetch(:rails_env)
    console_cmd = "$HOME/.rbenv/shims/bundle exec rails console -e #{env}"
    run_ssh_with(server, console_cmd)
  end
end

That's so much nicer! Before we finish though, there is one more thing we can do to appease our programmer nature that craves optimization in every keyboard keystroke. We can create aliases for our two tasks! Rake doesn't provide an alias feature per se but we can fake it. The way to do it is to define a new task whose execution will depend on invoking another task first. Let’s abbreviate both tasks to single letters:

# lib/capistrano/tasks/login.rake

task :c => :console
task :l => :login

Summary

We've finished a whirlwind tour of Capistrano. It's a lot to take in if you've never written a Capistrano script before. Even if you have, hopefully, this has clarified for you some of the Capistrano's features. This article should give you a general understanding of how a task works, how to configure variables, and how to run any command locally.

The login and console tasks provide a quick way of automating rudimentary jobs. One important side effect of the tasks is consistency across Rails projects. You don’t need to know the details to be able to quickly search the project files or query database on a remote server. These little efficiencies accumulate over time and create a smooth development workflow. If you have similar useful Capistrano tasks please share them!


This article was originally published on PiotrMurach.com.

Photo by Rock'n Roll Monkey on Unsplash

💖 💪 🙅 🚩
piotrmurach
Piotr Murach

Posted on November 17, 2019

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related