Practical Scripts#

Concepts#

This lesson brings together everything from the shell scripting module. No new concepts — just real-world application. Each script below is a practical tool you might actually use. Study them, run them, and modify them.


Lab#

Script 1: Backup Script#

A script that creates timestamped compressed backups of a directory.

cd ~/lab/scripts

cat > backup.sh << 'SCRIPT'
#!/bin/bash
set -euo pipefail

# --- Configuration ---
BACKUP_DIR="${HOME}/backups"

# --- Functions ---
die() { echo "ERROR: $1" >&2; exit 1; }
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1"; }

usage() {
    echo "Usage: $0 <source_directory>"
    echo "Creates a timestamped backup in $BACKUP_DIR"
    exit 1
}

# --- Validate ---
[[ $# -eq 1 ]] || usage

SOURCE="$1"
[[ -d "$SOURCE" ]] || die "Source directory does not exist: $SOURCE"

# --- Prepare ---
mkdir -p "$BACKUP_DIR"

BASENAME=$(basename "$SOURCE")
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="${BACKUP_DIR}/${BASENAME}_${TIMESTAMP}.tar.gz"

# --- Execute ---
log "Starting backup of '$SOURCE'"
log "Destination: $BACKUP_FILE"

tar -czf "$BACKUP_FILE" -C "$(dirname "$SOURCE")" "$BASENAME"

SIZE=$(du -h "$BACKUP_FILE" | cut -f1)
log "Backup complete: $BACKUP_FILE ($SIZE)"

# --- Show recent backups ---
echo ""
echo "Recent backups:"
ls -lht "$BACKUP_DIR"/${BASENAME}_*.tar.gz 2>/dev/null | head -5
SCRIPT

chmod +x backup.sh

# Test it
mkdir -p ~/lab/testdata
echo "important file" > ~/lab/testdata/data.txt
echo "config" > ~/lab/testdata/settings.conf

./backup.sh ~/lab/testdata
ls -lh ~/backups/

Script 2: Log Analyzer#

A script that parses a log file and reports statistics.

cd ~/lab/scripts

# Create a sample log file
cat > sample.log << 'EOF'
2024-10-15 08:00:01 INFO  Server started on port 8080
2024-10-15 08:00:05 INFO  Database connected
2024-10-15 08:05:12 WARN  Slow query: SELECT * FROM users (3.2s)
2024-10-15 08:10:33 ERROR Connection timeout to payment API
2024-10-15 08:10:34 ERROR Retry 1/3: payment API
2024-10-15 08:10:37 INFO  Payment API reconnected
2024-10-15 08:15:00 INFO  Processing batch job #42
2024-10-15 08:20:45 WARN  High memory usage: 87%
2024-10-15 08:25:00 ERROR Disk space low: /var/log at 92%
2024-10-15 08:30:00 INFO  Batch job #42 completed
2024-10-15 08:35:12 INFO  Health check: OK
2024-10-15 08:40:00 WARN  SSL certificate expires in 7 days
2024-10-15 08:45:33 ERROR Out of memory: worker process killed
2024-10-15 08:45:35 INFO  Worker process restarted
2024-10-15 08:50:00 INFO  Serving 1,247 active connections
EOF

cat > loganalyzer.sh << 'SCRIPT'
#!/bin/bash
set -euo pipefail

die() { echo "ERROR: $1" >&2; exit 1; }

[[ $# -eq 1 ]] || die "Usage: $0 <logfile>"
[[ -f "$1" ]] || die "File not found: $1"

LOGFILE="$1"
TOTAL=$(wc -l < "$LOGFILE")

echo "===== Log Analysis: $(basename "$LOGFILE") ====="
echo "Total entries: $TOTAL"
echo ""

# Count by level
echo "--- Counts by Level ---"
for level in INFO WARN ERROR; do
    count=$(grep -c "$level" "$LOGFILE" || true)
    pct=0
    (( TOTAL > 0 )) && pct=$((count * 100 / TOTAL))
    printf "  %-8s %3d  (%d%%)\n" "$level" "$count" "$pct"
done

echo ""
echo "--- Error Messages ---"
grep "ERROR" "$LOGFILE" | while IFS= read -r line; do
    echo "  ${line}"
done

echo ""
echo "--- Timeline ---"
first=$(head -1 "$LOGFILE" | cut -d' ' -f1,2)
last=$(tail -1 "$LOGFILE" | cut -d' ' -f1,2)
echo "  First entry: $first"
echo "  Last entry:  $last"
SCRIPT

chmod +x loganalyzer.sh
./loganalyzer.sh sample.log

Script 3: Batch File Renamer#

A script that renames files in bulk based on a pattern.

cd ~/lab/scripts

cat > renamer.sh << 'SCRIPT'
#!/bin/bash
set -euo pipefail

die() { echo "ERROR: $1" >&2; exit 1; }

usage() {
    cat << EOF
Usage: $0 [options] <directory>

Options:
  -p PREFIX    Add prefix to filenames
  -s OLD NEW   Replace OLD with NEW in filenames
  -l           Convert filenames to lowercase
  -d           Dry run (show what would happen, don't rename)
  -h           Show this help

Examples:
  $0 -p "2024_" ./photos
  $0 -s ".jpeg" ".jpg" ./photos
  $0 -l ./documents
EOF
    exit 0
}

# --- Parse arguments ---
DRY_RUN=false
MODE=""
PREFIX=""
OLD_STR=""
NEW_STR=""

while getopts "p:s:ldh" opt; do
    case "$opt" in
        p) MODE="prefix"; PREFIX="$OPTARG" ;;
        s) MODE="substitute"; OLD_STR="$OPTARG"; NEW_STR="${!OPTIND}"; OPTIND=$((OPTIND + 1)) ;;
        l) MODE="lowercase" ;;
        d) DRY_RUN=true ;;
        h) usage ;;
        *) usage ;;
    esac
done
shift $((OPTIND - 1))

[[ $# -eq 1 ]] || die "Missing directory argument. Use -h for help."
DIR="$1"
[[ -d "$DIR" ]] || die "Not a directory: $DIR"
[[ -n "$MODE" ]] || die "No operation specified. Use -h for help."

# --- Execute ---
$DRY_RUN && echo "=== DRY RUN ==="

count=0
for filepath in "$DIR"/*; do
    [[ -f "$filepath" ]] || continue

    filename=$(basename "$filepath")
    dirname=$(dirname "$filepath")

    case "$MODE" in
        prefix)     newname="${PREFIX}${filename}" ;;
        substitute) newname="${filename//$OLD_STR/$NEW_STR}" ;;
        lowercase)  newname="${filename,,}" ;;
    esac

    if [[ "$filename" != "$newname" ]]; then
        if $DRY_RUN; then
            echo "  Would rename: $filename → $newname"
        else
            mv "$filepath" "$dirname/$newname"
            echo "  Renamed: $filename → $newname"
        fi
        ((count++))
    fi
done

echo "Total: $count file(s) ${DRY_RUN:+would be }renamed."
SCRIPT

chmod +x renamer.sh

# Create test files
mkdir -p ~/lab/renametest
touch ~/lab/renametest/{"Photo 1.JPG","Photo 2.JPG","Document.TXT","notes.Md"}

# Dry run: lowercase
./renamer.sh -d -l ~/lab/renametest

# Actually do it
./renamer.sh -l ~/lab/renametest
ls ~/lab/renametest

# Clean up
rm -rf ~/lab/renametest

Script 4: System Health Report#

A comprehensive system information script.

cd ~/lab/scripts

cat > healthcheck.sh << 'SCRIPT'
#!/bin/bash
set -euo pipefail

WARN_CPU=80
WARN_MEM=80
WARN_DISK=80

bar() {
    local pct=$1 width=30
    local filled=$((pct * width / 100))
    local empty=$((width - filled))
    printf "[%s%s] %3d%%" \
        "$(printf '#%.0s' $(seq 1 $filled 2>/dev/null))" \
        "$(printf '-%.0s' $(seq 1 $empty 2>/dev/null))" \
        "$pct"
}

echo "=============================="
echo "   System Health Report"
echo "   $(date '+%Y-%m-%d %H:%M:%S')"
echo "=============================="

echo ""
echo "--- System ---"
echo "Hostname: $(hostname)"
echo "OS:       $(grep PRETTY_NAME /etc/os-release | cut -d= -f2 | tr -d '"')"
echo "Kernel:   $(uname -r)"
echo "Uptime:   $(uptime -p)"

echo ""
echo "--- CPU ---"
load=$(awk '{print $1}' /proc/loadavg)
cores=$(nproc)
load_pct=$(echo "$load $cores" | awk '{printf "%d", ($1/$2)*100}')
echo "Load average: $load (${cores} cores)"
echo "Usage: $(bar "$load_pct")"
(( load_pct >= WARN_CPU )) && echo "  ⚠ CPU load is high!"

echo ""
echo "--- Memory ---"
mem_total=$(free -m | awk '/Mem:/ {print $2}')
mem_used=$(free -m | awk '/Mem:/ {print $3}')
mem_pct=$((mem_used * 100 / mem_total))
echo "Used: ${mem_used}M / ${mem_total}M"
echo "Usage: $(bar "$mem_pct")"
(( mem_pct >= WARN_MEM )) && echo "  ⚠ Memory usage is high!"

echo ""
echo "--- Disk ---"
while read -r fs size used avail pct mount; do
    pct_num=${pct%\%}
    echo "$mount ($fs): $used used of $size"
    echo "Usage: $(bar "$pct_num")"
    (( pct_num >= WARN_DISK )) && echo "  ⚠ Disk space is low!"
done < <(df -h --type=ext4 --type=xfs --type=btrfs --type=tmpfs 2>/dev/null | tail -n +2 | head -5)

echo ""
echo "--- Services ---"
for svc in ssh cron; do
    if systemctl is-active --quiet "$svc" 2>/dev/null; then
        echo "  $svc: running"
    else
        echo "  $svc: STOPPED"
    fi
done

echo ""
echo "--- Recent Errors (last hour) ---"
errors=$(journalctl -p err --since "1 hour ago" --no-pager -q 2>/dev/null | wc -l)
echo "  $errors error(s) in the last hour"

echo ""
echo "=============================="
SCRIPT

chmod +x healthcheck.sh
./healthcheck.sh

Clean Up#

# Keep the scripts directory for reference, or clean up:
# rm -rf ~/lab/scripts ~/lab/testdata ~/backups

Review#

1. What makes a script "production-ready"?

The safety header (set -euo pipefail), input validation, meaningful error messages to stderr, cleanup via trap, clear usage instructions, and exit codes that reflect success or failure.

2. How do you parse command-line options in a script?

Use getopts for simple option parsing. It handles -p value, -l, -d, etc. Use shift $((OPTIND - 1)) after parsing to access remaining positional arguments.

3. What is a "dry run" and why is it useful?

A dry run shows what the script would do without actually doing it. It lets users verify the script’s behavior before making changes. Especially important for destructive operations like file renaming or deletion.

4. How do you ensure temporary files are cleaned up even if the script fails?

Use trap 'rm -f "$tmpfile"' EXIT. The EXIT trap runs when the script exits for any reason — success, failure, or signal.

5. What is the purpose of a `die()` function?

A die() function provides a consistent way to report errors and exit. It prints the error message to stderr and exits with a non-zero code. Example: die() { echo "ERROR: $1" >&2; exit "${2:-1}"; }.


Previous: Functions and Error Handling | Next: Networking Concepts