cyberangles guide

Ruby Shell Scripting: Automate System Tasks Like a Pro

In the world of system administration, DevOps, and everyday productivity, automation is king. Whether you’re managing servers, cleaning up log files, backing up data, or generating reports, repetitive tasks eat up valuable time. While traditional shell scripts (Bash, Zsh) are popular for automation, **Ruby** offers a compelling alternative. With its readable syntax, rich standard library, and object-oriented design, Ruby lets you write powerful, maintainable scripts that handle complex logic with ease. This blog will guide you through Ruby shell scripting, from the basics of writing your first script to advanced techniques for professional-grade automation. By the end, you’ll be equipped to replace clunky Bash scripts with Ruby’s elegance and power.

Table of Contents

  1. Why Ruby for Shell Scripting?
  2. Getting Started: Your First Ruby Shell Script
  3. Core Concepts for System Automation
  4. Practical Examples: Automate Real-World Tasks
  5. Advanced Techniques
  6. Best Practices for Ruby Shell Scripting
  7. Essential Tools & Libraries
  8. Conclusion
  9. References

Why Ruby for Shell Scripting?

Before diving in, let’s address the elephant in the room: Why use Ruby instead of Bash, Python, or Perl? Here’s why Ruby shines for system automation:

  • Readable Syntax: Ruby’s English-like syntax makes scripts easy to write and maintain. For example, FileUtils.cp('source.txt', 'dest/') is more intuitive than cp source.txt dest/ in Bash (especially for complex operations).
  • Rich Standard Library: Ruby’s stdlib includes modules like FileUtils (file operations), Open3 (advanced command execution), and OptionParser (CLI argument parsing) out of the box—no need for external dependencies.
  • Object-Oriented Power: Model complex tasks with classes/objects (e.g., a BackupManager class to handle backups).
  • Cross-Platform Compatibility: Write once, run on Linux, macOS, and even Windows (with Ruby installed).
  • Exception Handling: Gracefully handle errors (e.g., missing files, permission issues) with begin/rescue blocks, avoiding script crashes.

Getting Started: Your First Ruby Shell Script

Let’s write a simple script to verify Ruby is set up and demonstrate basic execution.

Step 1: Check Ruby Installation

Ensure Ruby is installed:

ruby -v  # Should output something like "ruby 3.2.2 (2023-03-30 revision e51014f9c0) [x86_64-linux]"  

If not, install Ruby via rbenv, rvm, or your system’s package manager (e.g., sudo apt install ruby on Ubuntu).

Step 2: Write Your First Script

Create a file named hello_automation.rb with:

#!/usr/bin/env ruby  
# hello_automation.rb  

# Get the current user (uses environment variable)  
user = ENV['USER'] || 'there'  

# Print a greeting  
puts "Hello, #{user}! 👋 Welcome to Ruby shell scripting."  

# List files in the current directory (using backticks to run system commands)  
puts "\nHere are your files:"  
puts `ls -la`  

Step 3: Run the Script

Make the script executable and run it:

chmod +x hello_automation.rb  
./hello_automation.rb  

Output:

Hello, alice! 👋 Welcome to Ruby shell scripting.  

Here are your files:  
total 16  
drwxr-xr-x 2 alice alice 4096 Sep 10 14:30 .  
drwxr-xr-x 5 alice alice 4096 Sep 10 14:25 ..  
-rwxr-xr-x 1 alice alice  267 Sep 10 14:30 hello_automation.rb  

Key Takeaways:

  • The shebang line #!/usr/bin/env ruby tells the system to run the script with Ruby.
  • ENV['USER'] accesses environment variables (fallback to ‘there’ if undefined).
  • Backticks (`ls -la`) execute system commands and return their output as a string.

Core Concepts for System Automation

To build robust scripts, master these fundamentals:

1. Executing System Commands

Ruby offers multiple ways to run shell commands; choose based on your needs:

MethodUse CaseReturnsExample
Backticks (`)Capture output (stdout)String (output)files = ls“
system('cmd')Check success/failure (exit status)true (success), false/nilsuccess = system('mkdir backups')
%x{cmd}Alias for backticks (more readable)String (output)disk_usage = %x{df -h}
Open3 (stdlib)Full control (stdin/stdout/stderr/exit)Multiple valuesSee example below

Advanced: Open3 for Full Control
Use Open3 to capture stdout, stderr, and exit status separately (avoids “hidden” errors):

require 'open3'  

stdout, stderr, status = Open3.capture3('ls -la non_existent_dir')  

puts "Output: #{stdout}"  
puts "Errors: #{stderr}"  # "ls: cannot access 'non_existent_dir': No such file or directory"  
puts "Exit status: #{status.exitstatus}"  # 2 (non-zero = failure)  

2. File & Directory Operations

Avoid raw system('cp ...') commands—use Ruby’s built-in FileUtils (part of stdlib) for safer, cross-platform file handling:

require 'fileutils'  

# Create a directory (with parent dirs if needed)  
FileUtils.mkdir_p('backups/2024-09-10')  # `-p` = "parent" (no error if exists)  

# Copy files  
FileUtils.cp('data.csv', 'backups/2024-09-10/')  

# Move/rename files  
FileUtils.mv('old_logs.txt', 'archive/')  

# Delete files/dirs (use with caution!)  
FileUtils.rm('temp.txt')  # Delete file  
FileUtils.rm_rf('tmp/')   # Delete dir recursively (`-rf` = force)  

# Symlink  
FileUtils.ln_s('source.txt', 'link_to_source')  

# Check if a file exists  
if File.exist?('critical.config')  
  puts "Config file found!"  
end  

3. Input/Output Handling

Read from files, write to logs, or process user input:

Reading Files

# Read entire file into a string  
content = File.read('notes.txt')  

# Read line-by-line (memory-efficient for large files)  
File.foreach('large_log.log') do |line|  
  puts line if line.include?('ERROR')  # Print error lines  
end  

Writing Files

# Write to a file (overwrites existing content)  
File.write('greeting.txt', "Hello from Ruby!\n")  

# Append to a file  
File.open('log.txt', 'a') do |file|  # 'a' = append mode  
  file.puts "[#{Time.now}] Script ran successfully"  
end  

User Input

Read from STDIN (standard input) for interactive scripts:

print "Enter your name: "  
name = gets.chomp  # `gets` reads input; `chomp` removes newline  
puts "Hello, #{name}!"  

3. Environment Variables & Configuration

Store sensitive data (API keys) or script settings in environment variables or config files:

# Access environment variables  
api_key = ENV['MY_APP_API_KEY'] || 'default_key'  

# Load config from a YAML file (install `yaml` gem if needed)  
require 'yaml'  
config = YAML.load_file('config.yaml')  # config.yaml: { "backup_dir": "backups" }  
backup_dir = config['backup_dir']  

4. Error Handling & Debugging

Prevent scripts from crashing with begin/rescue blocks:

require 'fileutils'  

begin  
  FileUtils.cp('important.txt', 'backups/')  
  puts "Backup successful!"  
rescue Errno::ENOENT => e  # File not found  
  puts "Error: #{e.message} (file missing)"  
rescue Errno::EACCES => e  # Permission denied  
  puts "Error: #{e.message} (check permissions)"  
rescue => e  # Catch-all for other errors  
  puts "Unexpected error: #{e.message}"  
end  

Debugging Tip: Use puts or pp (pretty-print) to inspect variables:

require 'pp'  
data = { name: 'Ruby', features: ['OOP', 'Readable'] }  
pp data  # Pretty-printed hash (easier to read than `puts data`)  

Practical Examples: Automate Real-World Tasks

Let’s build scripts for common system tasks.

Example 1: Automated File Backup

Goal: Zip a directory and save it to a timestamped backup folder.

#!/usr/bin/env ruby  
require 'fileutils'  
require 'time'  

# Configuration  
SOURCE_DIR = '/home/alice/documents'  # Dir to back up  
BACKUP_ROOT = '/home/alice/backups'  
TIMESTAMP = Time.now.strftime('%Y%m%d_%H%M%S')  # e.g., 20240910_153045  
BACKUP_DIR = "#{BACKUP_ROOT}/doc_backup_#{TIMESTAMP}"  

begin  
  # Create backup dir  
  FileUtils.mkdir_p(BACKUP_DIR)  

  # Zip the source directory  
  zip_file = "#{BACKUP_DIR}/docs.zip"  
  puts "Zipping #{SOURCE_DIR} to #{zip_file}..."  
  system("zip -r #{zip_file} #{SOURCE_DIR}")  

  # Verify backup  
  if File.exist?(zip_file) && File.size(zip_file) > 0  
    puts "✅ Backup created: #{zip_file}"  
  else  
    raise "Backup failed: Zip file missing or empty"  
  end  
rescue => e  
  puts "❌ Backup failed: #{e.message}"  
  exit 1  # Exit with non-zero status (signals failure to cron/systemd)  
end  

Usage: Run manually or schedule with cron (daily backups at 2 AM):

# Add to crontab (run `crontab -e`)  
0 2 * * * /home/alice/scripts/backup_docs.rb >> /var/log/backup.log 2>&1  

Example 2: Log File Analyzer

Goal: Count ERROR/WARN entries in a log file and generate a report.

#!/usr/bin/env ruby  

# Usage: ./analyze_log.rb app.log  
log_file = ARGV[0] || 'app.log'  # ARGV[0] = first command-line argument  

unless File.exist?(log_file)  
  puts "Error: Log file '#{log_file}' not found."  
  exit 1  
end  

errors = 0  
warnings = 0  

File.foreach(log_file) do |line|  
  errors += 1 if line.include?('[ERROR]')  
  warnings += 1 if line.include?('[WARN]')  
end  

report = <<~REPORT  
  Log Analysis Report (#{log_file})  
  ==============================  
  Errors: #{errors}  
  Warnings: #{warnings}  
  Total issues: #{errors + warnings}  
REPORT  

puts report  
File.write('log_report.txt', report)  # Save to file  

Usage: ./analyze_log.rb /var/log/nginx/error.log

Example 3: System Cleanup Utility

Goal: Delete files older than 30 days in a directory (e.g., downloads).

#!/usr/bin/env ruby  
require 'fileutils'  

# Cleanup dir and age threshold (days)  
CLEANUP_DIR = '/home/alice/Downloads'  
DAYS_OLD = 30  
cutoff_time = Time.now - (DAYS_OLD * 24 * 60 * 60)  # 30 days in seconds  

puts "Cleaning files older than #{DAYS_OLD} days in #{CLEANUP_DIR}..."  

Dir.glob("#{CLEANUP_DIR}/*") do |path|  
  next unless File.file?(path)  # Skip directories  

  file_age = File.mtime(path)  # Last modified time  
  if file_age < cutoff_time  
    puts "Deleting: #{path}"  
    FileUtils.rm(path)  
  end  
end  

puts "Cleanup complete!"  

Example 4: System Health Reporter

Goal: Check CPU usage, disk space, and memory, then alert if thresholds are exceeded.

#!/usr/bin/env ruby  
require 'open3'  

# Thresholds (adjust as needed)  
CPU_THRESHOLD = 80  # %  
DISK_THRESHOLD = 85  # %  
MEM_THRESHOLD = 90   # %  

def check_cpu  
  # `top` output varies by OS; use `mpstat` (Linux) or `sysctl` (macOS)  
  stdout, = Open3.capture3('mpstat 1 1')  # 1 sample, 1 second delay  
  idle = stdout.scan(/(\d+\.\d+)\s*$/).last.first.to_f  # Extract idle %  
  usage = 100 - idle  
  { usage: usage.round(1), ok: usage < CPU_THRESHOLD }  
end  

def check_disk  
  stdout, = Open3.capture3('df -h /')  # Check root filesystem  
  used_pct = stdout.split("\n")[1].split[4].to_i  # Extract used %  
  { usage: used_pct, ok: used_pct < DISK_THRESHOLD }  
end  

# Run checks  
cpu = check_cpu  
disk = check_disk  

# Generate report  
report = "System Health Check\n"  
report += "CPU Usage: #{cpu[:usage]}% (#{cpu[:ok] ? 'OK' : 'ALERT!'})\n"  
report += "Disk Usage: #{disk[:usage]}% (#{disk[:ok] ? 'OK' : 'ALERT!'})\n"  

puts report  

# Send alert (e.g., email or Slack; use `mail` command or API)  
unless cpu[:ok] && disk[:ok]  
  system("echo '#{report}' | mail -s 'System Alert' [email protected]")  
end  

Advanced Techniques

Command-Line Arguments with OptionParser

Use OptionParser (stdlib) to add flags like --help, --verbose, or --dir:

require 'optparse'  

options = { verbose: false, dir: '.' }  

parser = OptionParser.new do |opts|  
  opts.banner = "Usage: ./script.rb [options]"  

  opts.on('-v', '--verbose', 'Enable verbose output') { options[:verbose] = true }  
  opts.on('-d', '--dir DIR', 'Target directory') { |d| options[:dir] = d }  
  opts.on_tail('-h', '--help', 'Show this message') { puts opts; exit }  
end  

begin  
  parser.parse!(ARGV)  
rescue OptionParser::InvalidOption => e  
  puts e.message  
  puts parser  
  exit 1  
end  

puts "Verbose: #{options[:verbose]}, Directory: #{options[:dir]}"  

Usage: ./script.rb -v --dir /tmp

Parallel Processing

Speed up tasks (e.g., processing multiple files) with threads:

require 'thread'  

files = Dir.glob('data/*.txt')  
threads = []  

files.each do |file|  
  threads << Thread.new(file) do |f|  # Pass file to thread  
    puts "Processing #{f} (thread #{Thread.current.object_id})"  
    # Add heavy processing here (e.g., parsing large files)  
  end  
end  

threads.each(&:join)  # Wait for all threads to finish  
puts "All files processed!"  

Best Practices for Ruby Shell Scripting

  1. Keep It Readable: Use meaningful variable names (backup_dir vs bd).
  2. Modularize: Split logic into methods/classes (e.g., def backup_file(path)).
  3. Test: Use minitest or rspec to test edge cases (e.g., missing files).
  4. Secure Inputs: Sanitize user/command-line inputs to avoid shell injection:
    # UNSAFE: User input directly in command  
    # system("rm -rf #{user_input}")  # Risky if user_input is '../'  
    
    # SAFE: Use array syntax with Open3 (no shell interpolation)  
    Open3.capture3('rm', '-rf', user_input)  # `user_input` is treated as a literal  
  5. Avoid Overhead: Prefer Ruby methods (FileUtils.cp) over system('cp') (faster, cross-platform).

Essential Tools & Libraries

Enhance scripts with these gems:

  • Thor: Build CLI apps with subcommands (e.g., script backup, script restore).
  • TTY::Prompt: Interactive menus/checkboxes for user input.
  • Colorize: Add color to output (puts "Error".red).
  • Dotenv: Load environment variables from .env files (no more export in shell).

Conclusion

Ruby shell scripting combines the power of a programming language with the simplicity of shell scripts. Its readable syntax, rich standard library, and cross-platform support make it ideal for automating system tasks—from backups to log analysis. By mastering core concepts like FileUtils, Open3, and error handling, and following best practices, you’ll write scripts that are maintainable, secure, and efficient.

Start small (e.g., a daily cleanup script), then level up with advanced tools like Thor or parallel processing. Happy automating!

References