Enhanced Shell Scripting with Ruby

Ruby is a better Perl and in my opinion is an essential language for system administrators. If you are still writing scripts in Bash, I hope this inspires you to start integrating Ruby in to your shell scripts. I will show you how you can ease in to it and make the transition very smoothly.

The idea with 'enhanced shell scripting' is to create hybrid Ruby/Bash scripts. The reason for this is to take advantage of both worlds. Ruby makes it seamless to pass data back and forth with shell commands.

There are many times when running shell commands is easier or required when there is an external command-line utilities you need to run.

On the other hand, Bash syntax is quite ugly and difficult to remember, and it has very limited features. Ruby offers two tons of object-oriented power, along with tons of libraries and better syntax.

We will look at how to write 'enhanced shell scripts' using Ruby and other tips on taking advantage of both worlds.

You can execute shell commands in Ruby, making it easy to run external programs.

In Ruby, backticks allow shell execution to provide seamless back and forth with shell. This makes it easy to convert an existing shell script to Ruby. You can simply wrap everything in back ticks and start porting over the sections needed to Ruby.

#!/usr/bin/ruby `cd ~`
puts `pwd`
puts `uname -a`.split

Check for return codes and errors with the $? special object. This is similar to the $? used in Bash except it is a Ruby object with more information. This is critical for error checking and handling Bash scripts

#!/usr/bin/ruby # Execute some shell command
`pwd` # Was the app run successful?
puts $?.success? # Process id of the exited app
puts $?.pid # The actual exit status code
puts $?.exitstatus

The $? object will work properly from commands run using backticks. For example:

#!/usr/bin/ruby `touch /root/test.txt` if $?.exitstatus != 0 puts 'Error happened!'

If you have a Bash script and you only want to run a little bit of Ruby code, you have a couple options. You can pass the code as an argument using the ruby -e flag or you can create a heredoc and pass a block of code.

#!/bin/bash echo "This is a Bash script, but may want to call Ruby." ruby -e 'puts "you can jam"; puts "a whole script in one line";'

If you are working in a bash script but want to execute a block of Ruby code, you can use a heredoc

#!/bin/bash echo "This is a Bash script executing some Ruby" /usr/bin/ruby <<EOF
puts 'Put some Ruby'
puts 'code here'

There are several ways to get input in to Ruby. You can take command-line arguments, read environment variables, use interactive prompts, read in config files like JSON, or use STDIN to receive piped input. We will look at each of these options and more.

Command-line arguments are passed in through the ARGV object. Unlike some other languages, Ruby does not include the name of the script being executed as an argument. So if you ran ruby myscript.rb the ARGV object would be empty. The first argument that gets passed to the script happens.

#!/usr/bin/ruby ARGV.each { |arg| puts arg
} # Or access individual elements
# No error will be thrown if the arg does not exist
puts ARGV[1]

You can get and set environment variables using the ENV object. Note that any environment variables you set are lost when the Ruby script is done running.

#!/usr/bin/ruby # Get an environment variable
puts ENV['SHELL'] # Set an environment variable (only lasts during Ruby session)
ENV['SOME_VAR'] = 'test'

The gets prompt is good for getting interactive input from the user.

#!/usr/bin/ruby print "Enter something: "
x = gets.chomp.upcase
puts "You entered: #{x}"

This is similar to prompting the user for input, only the terminal output should not be echoed back for security purposes. This is only available in Ruby 2.3 and newer.

require 'io/console' # The prompt is optional
password = IO::console.getpass "Enter Password: "
puts "Your password was #{password.length} characters long."

To learn more see my tutorial Get Password in Console with Ruby.

The ARGF object is a virtual file that either takes input from STDIN or takes the command-line arguments from ARGV and loads files by name. This allows flexible usage of your script with zero effort.

You can pass a file to it by piping it a couple ways:

#!/bin/bash # ARGF will process these from STDIN
cat myfile.txt | ruby myscript.rb
ruby myscript.rb < myfile.txt # ARGF will load these files by name (as if one big input was provided to STDIN)
ruby myscript.rb myfile.txt myfile2.txt

In Ruby, you use ARGF like this:

#!/usr/bin/ruby # ARGF will take in a file argument or use STDIN if no file
# If multiple file names are provided, it cats them all together
# and treats it as one big input.
ARGF.each do |line| puts line

There are a few more options but that is the basic idea. Read more about ARGF at https://ruby-doc.org/core-2.6.3/ARGF.html.

JSON files are a convenient way to store settings information. This is a simple example of how to read a JSON config file to pull some data.

require 'json' # If there is a file named `settings.json` that contains:
# {"data": 42} json_object = JSON.parse(File.read('settings.json'))
puts json_object['data']
# Outputs: 42

There are several methods of outputting information from Ruby. You can use the obvious STDOUT and STDERR and you can also specify an exit status code to pass on to any calling program. We will also

You can write to STDOUT using puts and print.

#!/usr/bin/ruby # STDOUT is default output target
puts 'Text with newline'
print 'Text without newline' STDOUT.puts 'Equivalent to puts'
STDOUT.print 'Equivalent to print'

It is important to separate STDOUT and STDERR output to allow proper piping of applications. Debug output, and anything that does not belong in the output of the application should go to STDERR and only data that should be piped to anothe application or stored should go to STDOUT

#!/usr/bin/ruby STDERR.puts 'This will go to STDERR instead of STDOUT'
STDERR.print 'This will print with no newline at the end.'

You can easily write to a file in Ruby like this:

#!/usr/bin/ruby open('test.txt', 'w') { |output_file| output_file.print 'Write to it just like STDOUT or STDERR' output_file.puts 'print(), puts(), and write() all work.'

You can easily add color to your output using the colorize module.

# In the system terminal
gem install colorize
gem install win32console # For windows
require 'colorize' puts "Blue text".blue
puts 'Bold cyan on blue text'.cyan.on_blue.bold
puts "This is #{"fancy".red} text"

Learn more in my full tutorial Colorize Ruby Terminal Output.

To play well with other programs, your Ruby script should return a proper exit status indicating wether it exited with success or other status.

#!/usr/bin/ruby # Equivalent success exit codes
exit(true) # Error or other status

Ruby has first-class regular expressions and can be used directly in the language with the =~ operator. For example:

#!/usr/bin/ruby if `whoami`.upcase =~ /^NANODANO$/ puts 'You are nanodano!'

This makes Ruby a lot more like Perl. Regular expressions in Python are very ugly compared to this. Since regular expressions are so common in shell scripting, it feels right at home in Ruby.

Ruby provides modules for interacting with processes. For example, forking or getting information about its own process ID. Read more about the Process module at https://ruby-doc.org/core-2.6.3/Process.html.

You can get your current process ID with Process.pid.

puts Process.pid

Forking can be confusing, but essentially it creates an exact duplicate of the current process in memory that gets assigned a unique PID as the child process or the original. Both processes will then continue running the rest of the code. You can use the process IDs to determine if the running process is the parent or child. You can simply call fork and you will have two processes.

Here is a simple example of forking:

#!/usr/bin/ruby # There is only one process at this time, the parent.
parent_pid = Process.pid # After this line executes, there will be two copies of
# this program running with separate pids.
# child_pid will be empty in the child process, since it
# hadn't started yet and parent process will have a non-nil child_pid value
child_pid = fork # The parent and child will print out different pids at this point
puts Process.pid
puts "Child pid: #{child_pid}" if Process.pid == parent_pid puts 'I am the parent!'
else puts 'I am a child!'

Alternatively, you can put the code to be executed in the forked process inside a block so it only executes a specific task.

#!/usr/bin/ruby fork do # Limit forked process to a block of code puts 'Starting child and working for one second...' sleep 1 puts 'Finishing child.'
end puts 'Waiting for child processes to finish...'
Process.wait # Wait for child processes to finish
puts 'Child processes finished. Closing.'

Here is an example of forking a child process and then sending it a signal to kill it and waiting for the child process to complete before exiting cleanly.

Read more about the Process.kill() function at https://ruby-doc.org/core-2.6.3/Process.html#method-c-kill.

#!/usr/bin/ruby parent_pid = Process.pid child_pid = fork do puts "My child pid is #{Process.pid} and my parent is #{parent_pid}" Signal.trap("HUP") { puts 'Signal caught. Exiting cleanly.' exit(true) } while true do end
end puts "Child pid is #{child_pid}"
puts "My pid is #{Process.pid}"
puts 'Killing child process now.'
Process.kill("HUP", child_pid)
puts 'Waiting for child process to finish.'
Process.wait # Waits for child processes to finish

Just as shown in the previous example, you can catch signals using Signal.trap() and in this case we want to watch for the SIGINT interrupt signal caused by pressing CTRL-C key combination.

#!/usr/bin/ruby Signal.trap("SIGINT") { puts 'Caught a CTRL-C / SIGINT. Shutting down cleanly.' exit(true)
} puts 'Running forever until CTRL-C / SIGINT signal is recieved.'
while true do end

Read more about Signal.trap() at https://ruby-doc.org/core-2.6.3/Signal.html#method-c-trap.

Some basic examples for working with common directory and file tasks like getting a user home directory, walking directories, globbing contents, and joining file paths.

This is only a few of the functions, not an extensive list, but you will see all the expected functions.


See more at https://ruby-doc.org/core-2.6.3/Dir.html and https://ruby-doc.org/core-2.6.3/File.html.

A common task is getting the path of the user's home directory.

#!/usr/bin/ruby # Get user home dir
puts Dir.home
# or a specific user
puts Dir.home 'nanodano'

To join file paths using the proper slashes for the operating system, use File.join. This is the equivalent of Python's os.path.join().

# Create the path to `~/.config` in a cross-platform way.
File.join(Dir.home, '.config')

You can easily glob a directory (get a list of objects) using Dir[] syntax. You can also use Dir.glob().

Dir['**/*'] ## Recursively go through directories and get all files # Or

Rake is a task execution tool for any kind of project, Ruby or not. Rake is great for managing several related tasks that may share some common code. It can make managing and executing scripts much simpler.

Here is a simple example Rakefile demonstrating how to create basic tasks:

# Rakefile task default: [:build, :install] # Will run :build then :install task :clean do puts "Cleaning"
end task :build => [:clean] do # Will run :clean first puts "Building"
end task :install do puts "Installing"

These tasks can be run using the following commands in the system terminal:

rake clean
rake build
rake install

Also check out my Ruby Rake Tutorial.

Hopefully this document has provided some inspiration to start using Ruby to enhance shell scripts. Ruby provides so much power over the Bash shell yet we still can't live without it so we might as well catalyze that synergy (tm).