Enhanced Shell Scripting with Ruby

Advertisement

Advertisement

Introduction

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.

Execute Bash commands in Ruby

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

Backticks

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

The $? object

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!'
end

Execute Ruby from Bash

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.

Execute Ruby in a single line

#!/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";'

Execute a block of code with heredoc

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'
EOF

Get input in Ruby

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

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]

Environment variables

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'

Prompt user for input

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}"

Prompt user for password

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.

#!/usr/bin/ruby
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

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
end

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.

Reading JSON config files

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.

#!/usr/bin/ruby
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

Output from Ruby

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

Writing to STDOUT

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'

Writing to STDERR

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.'

Writing to a file

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.'
}

Colorize terminal output

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

# In the system terminal
gem install colorize
gem install win32console # For windows
#!/usr/bin/ruby
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.

Specifying exit status code

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(0)
exit(true)

# Error or other status
exit(3)

Built-in regular expressions

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!'
end

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.

Processes

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.

Get Ruby's process ID

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

puts Process.pid

Fork a process

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!'
end

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.'

Kill a process

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

Trap an interrupt signal

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.

Working with files and directories

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.

Basics file functions

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

Dir.pwd
Dir.mkdir
Dir.rmdir
Dir.chdir
Dir.exist?
Dir.empty?
File.chmod
File.delete
File.empty?
File.executable?
File.exists?
File.readable?
File.size
File.new
File.open
File.mkfifo

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

Get user home dir

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'

Join file paths

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')

Glob a directory

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

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

# Or
Dir.glob('*.png')

Rake

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:

#!/usr/bin/ruby
# 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"
end

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

rake
rake clean
rake build
rake install

Also check out my Ruby Rake Tutorial.

Conclusion

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).

References

Advertisement

Advertisement