dotlinux guide

Exploring the Art of Shell Scripting: Strategies and Examples

Table of Contents

What is Shell Scripting?

A shell script is a text file containing a sequence of commands executed by a shell interpreter (e.g., Bash, Zsh, or Dash). The shell acts as an intermediary between the user and the operating system kernel, translating human-readable commands into system calls.

Shell scripts are lightweight, platform-agnostic (across Unix-like systems), and require no compilation—making them ideal for:

  • Automating file management (e.g., renaming, archiving).
  • System monitoring (e.g., CPU/memory usage alerts).
  • Deployment pipelines (e.g., code testing, server provisioning).
  • Repetitive tasks (e.g., log rotation, data backups).

Core Concepts of Shell Scripting

To harness the power of shell scripting, you must first master its foundational building blocks.

Variables and Data Types

Variables store data for reuse. Unlike compiled languages, shell scripts are dynamically typed (no explicit type declarations).

Syntax:

# Declare a variable (no spaces around =)
name="Alice"
age=30

# Access a variable with $
echo "Name: $name, Age: $age"

# Read user input into a variable
read -p "Enter your favorite color: " color
echo "Your favorite color is $color"

Key Types:

  • Strings: Enclosed in quotes (e.g., greeting="Hello, World!"). Use double quotes (" ") for variable expansion; single quotes (' ') for literal strings.
  • Integers: Numeric values (e.g., count=10). Arithmetic operations require $(( ... )):
    total=$((age + 5))  # Adds 5 to $age
  • Arrays: Ordered collections (Bash-specific):
    fruits=("apple" "banana" "cherry")
    echo "First fruit: ${fruits[0]}"  # Access by index
    echo "All fruits: ${fruits[@]}"   # Print all elements

Control Structures

Control structures dictate the flow of execution (conditionals, case statements).

if-else Statements

Check conditions (e.g., file existence, numeric comparisons).

file="data.txt"

if [ -f "$file" ]; then  # -f checks if $file is a regular file
  echo "$file exists."
elif [ -d "$file" ]; then  # -d checks if $file is a directory
  echo "$file is a directory."
else
  echo "$file does not exist."
fi

Common condition flags:

  • -f <file>: File exists and is regular.
  • -d <dir>: Directory exists.
  • -z <str>: String is empty.
  • -n <str>: String is non-empty.
  • $a -gt $b: $a > $b (numeric comparison).

case Statements

Simplify multi-condition checks (alternative to nested if-else).

read -p "Enter a day (1-7): " day

case $day in
  1) echo "Monday" ;;
  2) echo "Tuesday" ;;
  3|4|5) echo "Weekday" ;;  # Match 3, 4, or 5
  6|7) echo "Weekend" ;;
  *) echo "Invalid day" ;;  # Default case
esac

Loops

Loops iterate over data (e.g., files, lists, ranges).

for Loops

Iterate over a list of items.

# Loop over a static list
for fruit in apple banana cherry; do
  echo "I like $fruit"
done

# Loop over files in a directory
for file in *.txt; do
  echo "Processing $file"
done

# Loop over a range (Bash)
for i in {1..5}; do
  echo "Count: $i"
done

while Loops

Run commands as long as a condition is true.

count=1
while [ $count -le 5 ]; do  # Run until count > 5
  echo "Loop iteration: $count"
  count=$((count + 1))  # Increment count
done

Functions

Functions encapsulate reusable code blocks, improving readability and maintainability.

Syntax:

# Define a function
greet() {
  local name=$1  # $1 = first argument, local to the function
  echo "Hello, $name!"
}

# Call the function
greet "Bob"  # Output: Hello, Bob!

Key Features:

  • Parameters: Accessed via $1, $2, … (positional arguments).
  • Return Values: Use return for exit codes (0 = success, non-zero = error). For data, echo output and capture with $(...):
    add() {
      echo $(( $1 + $2 ))  # Echo result
    }
    sum=$(add 5 3)  # Capture output: sum=8

Input/Output Redirection

Shell scripts interact with input/output (I/O) streams to read from files, write to files, or chain commands.

Streams:

  • stdin (0): Standard input (default: keyboard).
  • stdout (1): Standard output (default: terminal).
  • stderr (2): Standard error (default: terminal).

Redirection Operators:

  • >: Overwrite a file with stdout (e.g., echo "Hi" > output.txt).
  • >>: Append stdout to a file (e.g., echo "Line 2" >> output.txt).
  • 2>: Redirect stderr to a file (e.g., command 2> errors.log).
  • &>: Redirect both stdout and stderr (e.g., command &> combined.log).
  • |: Pipe stdout of one command to stdin of another (e.g., ls -l | grep ".txt").

Strategies for Effective Shell Scripting

Writing a script that “works” is easy; writing one that is reliable, maintainable, and scalable requires strategy.

Modular Design

Break scripts into small, focused functions. This improves readability, reusability, and testability.

Example: Modular Backup Script

#!/bin/bash

# Function: Log messages with timestamps
log() {
  echo "[$(date +'%Y-%m-%d %H:%M:%S')] $1"
}

# Function: Validate backup source exists
validate_source() {
  local source=$1
  if [ ! -d "$source" ]; then
    log "Error: Source directory $source does not exist."
    exit 1  # Exit with error code 1
  fi
}

# Function: Perform backup with rsync
perform_backup() {
  local source=$1
  local dest=$2
  log "Starting backup from $source to $dest..."
  rsync -av --delete "$source"/ "$dest"/  # -av: archive/verbose, --delete: mirror source
  if [ $? -eq 0 ]; then  # $? = exit code of last command (0 = success)
    log "Backup completed successfully."
  else
    log "Backup failed."
    exit 1
  fi
}

# Main execution
SOURCE="/home/user/documents"
DEST="/mnt/backup/documents"

validate_source "$SOURCE"
perform_backup "$SOURCE" "$DEST"

Error Handling

Unhandled errors can crash scripts or cause data loss. Proactively manage errors with:

set -euo pipefail

Enable strict error checking at the start of your script:

  • -e: Exit immediately if any command fails.
  • -u: Treat undefined variables as errors.
  • -o pipefail: Exit if any command in a pipeline fails (not just the last one).
#!/bin/bash
set -euo pipefail  # Strict mode

trap for Cleanup

Use trap to run commands on script exit (e.g., delete temporary files).

#!/bin/bash
set -euo pipefail

# Create a temporary directory
TMP_DIR=$(mktemp -d)
log "Created temp dir: $TMP_DIR"

# Cleanup: Delete temp dir on exit (success or failure)
trap 'rm -rf "$TMP_DIR"; log "Cleaned up temp dir."' EXIT

# ... rest of script ...

Idempotency

Scripts should be idempotent—running them multiple times produces the same result as running them once. Avoid side effects like duplicate files or failed commands on re-runs.

Example: Idempotent Directory Creation

# Instead of: mkdir "logs" (fails if dir exists)
mkdir -p "logs"  # -p: Create parent dirs; no error if dir exists

Logging

Logs are critical for debugging and auditing. Always log key actions, errors, and timestamps.

Example: Logging to File and Terminal

#!/bin/bash
LOG_FILE="script.log"

# Log to both terminal and file (tee -a = append)
echo "Starting script..." | tee -a "$LOG_FILE"
# ... rest of script ...

Practical Examples

Let’s apply these concepts and strategies to real-world scenarios.

Example 1: File Organizer

Goal: Automatically sort files in a directory by extension (e.g., .txtDocuments, .jpgImages).

#!/bin/bash
set -euo pipefail

# Define source directory (default: current dir)
SOURCE_DIR="${1:-.}"  # Use first argument or . (current dir)

# Define extension-to-folder mappings
declare -A FOLDERS=(
  ["txt"]="Documents"
  ["pdf"]="Documents"
  ["jpg"]="Images"
  ["png"]="Images"
  ["sh"]="Scripts"
  ["zip"]="Archives"
  ["tar.gz"]="Archives"
)

# Create target folders if missing
for folder in "${FOLDERS[@]}"; do
  mkdir -p "$SOURCE_DIR/$folder"
done

# Organize files
find "$SOURCE_DIR" -maxdepth 1 -type f | while read -r file; do
  # Skip the script itself
  if [ "$(basename "$file")" = "$(basename "$0")" ]; then
    continue
  fi

  # Extract file extension (handle multi-part extensions like .tar.gz)
  ext=$(basename "$file" | sed -E 's/^.*\.([^.]+)$/\1/' | tr '[:upper:]' '[:lower:]')
  
  # Skip files with no extension
  if [ "$ext" = "$(basename "$file")" ]; then
    echo "Skipping file with no extension: $file"
    continue
  fi

  # Move file to target folder
  target_folder="${FOLDERS[$ext]:-Other}"  # Default to "Other" if extension not mapped
  mv -v "$file" "$SOURCE_DIR/$target_folder/"
done

echo "File organization complete!"

Usage:
Save as organize_files.sh, make executable with chmod +x organize_files.sh, and run with ./organize_files.sh [source_dir] (e.g., ./organize_files.sh ~/Downloads).

Example 2: System Monitoring Dashboard

Goal: Monitor CPU, memory, and disk usage; alert if thresholds are exceeded.

#!/bin/bash
set -euo pipefail

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

# Get metrics
CPU_USAGE=$(top -bn1 | grep "Cpu(s)" | awk '{print $2 + $4}')  # User + System CPU
MEM_USAGE=$(free -m | awk '/Mem:/ {print $3/$2 * 100}')       # Used memory %
DISK_USAGE=$(df -h / | awk '/\// {print $5}' | sed 's/%//')   # Root disk usage %

# Function: Check threshold and alert
check_alert() {
  local metric=$1
  local value=$2
  local threshold=$3
  if (( $(echo "$value > $threshold" | bc -l) )); then  # bc for floating-point comparison
    echo "ALERT: $metric usage is $(printf "%.1f" "$value")% (Threshold: $threshold%)"
  else
    echo "OK: $metric usage is $(printf "%.1f" "$value")%"
  fi
}

# Run checks
echo "=== System Monitoring Report ==="
check_alert "CPU" "$CPU_USAGE" "$CPU_THRESHOLD"
check_alert "Memory" "$MEM_USAGE" "$MEM_THRESHOLD"
check_alert "Disk (/)" "$DISK_USAGE" "$DISK_THRESHOLD"

Usage:
Save as system_monitor.sh, make executable, and run. For continuous monitoring, add to cron (e.g., run every 5 minutes).

Example 3: Automated Backup with Rsync

Goal: Back up a directory to a remote server or external drive, with logging and error handling.

#!/bin/bash
set -euo pipefail

# Configuration
SOURCE="/home/user/photos"
DEST="[email protected]:/mnt/backup/photos"
LOG_FILE="/var/log/photo_backup.log"
EXCLUDE_LIST=("*.tmp" "cache/")  # Files/dirs to exclude

# Log function
log() {
  echo "[$(date +'%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
}

# Validate source
if [ ! -d "$SOURCE" ]; then
  log "ERROR: Source directory $SOURCE does not exist."
  exit 1
fi

# Build rsync exclude arguments
EXCLUDE_ARGS=()
for item in "${EXCLUDE_LIST[@]}"; do
  EXCLUDE_ARGS+=(--exclude "$item")
done

# Run rsync
log "Starting backup from $SOURCE to $DEST..."
rsync -avz "${EXCLUDE_ARGS[@]}" "$SOURCE"/ "$DEST"/ >> "$LOG_FILE" 2>&1

# Check rsync exit code
if [ $? -eq 0 ]; then
  log "Backup completed successfully."
else
  log "ERROR: Backup failed. Check $LOG_FILE for details."
  exit 1
fi

Usage:
Save as photo_backup.sh, configure SOURCE, DEST, and EXCLUDE_LIST, then run. For passwordless remote access, set up SSH keys.

Best Practices

To elevate your scripts from “working” to “production-ready,” follow these best practices:

  1. Start with a Shebang: Always specify the shell interpreter:

    #!/bin/bash  # Use Bash-specific features
    #!/bin/sh    # Use POSIX-compliant shell (portable)
  2. Enable Strict Mode: Use set -euo pipefail to catch errors early:

    • -e: Exit on command failure.
    • -u: Treat undefined variables as errors.
    • -o pipefail: Exit if any pipeline command fails.
  3. Quote Variables: Always quote variables to avoid word splitting (e.g., rm "$file" instead of rm $file).

  4. Use Functions for Repeating Code: Reduce redundancy and improve readability.

  5. Document with Comments: Explain why, not just what. Include usage instructions at the top: