Skip to main content
RunBook Academy

← All labs in Linux

Lab · intermediate · ~60 min

Lab: Write a production shell script with full error handling

B · Nested virtualisationC · Simulation

Objectives

  • Write a complete production shell script
  • Apply error handling and traps
  • Lint with ShellCheck
  • Test the script

Prerequisites

This lab writes a production-grade shell script from scratch. The script cleans up old log files based on age.

Run every task in this lab as your normal user. Nothing here needs root, and a script that deletes files should be proven unprivileged before it is ever given root.

Tasks

Task 1: Choose a script to write

Write a script cleanup-logs.sh that:

  • Takes a directory path as argument.
  • Deletes files older than N days (default 30).
  • Skips files matching certain patterns.
  • Logs what it deletes.
  • Handles errors gracefully.
  • Offers a dry run, because it deletes files.

Task 2: Write the script

#!/usr/bin/env bash
#---BEGIN-USAGE
# cleanup-logs.sh - delete old log files from a directory
#
# Usage: cleanup-logs.sh [options] <directory>
#
# Options:
#   -n DAYS    Delete files older than DAYS days (default 30)
#   -p PATTERN Skip files matching PATTERN (default: '*.gz')
#   -d         Dry run: report what would be deleted, delete nothing
#   -h         Show this help
#
# Environment:
#   LOGFILE    Log destination (default: $XDG_STATE_HOME/cleanup-logs.log,
#              falling back to $HOME/.local/state/cleanup-logs.log)
#
# Exit codes:
#   0    Success
#   1    Invalid arguments
#   2    Directory not found
#   3    Permission denied
#   4    Log file not writable
#   5    Another instance is running
#
#---END-USAGE
#
# Author: <name>
# Date: 2026-08-09

set -euo pipefail
IFS=$'\n\t'

# Defaults
DAYS=30
PATTERN='*.gz'
DRY_RUN=0
LOGFILE="${LOGFILE:-${XDG_STATE_HOME:-${HOME:-/tmp}/.local/state}/cleanup-logs.log}"
LOCKDIR="${TMPDIR:-/tmp}/cleanup-logs.lock"
LOCK_HELD=0

# Functions
log() {
    local line
    line="[$(date -Is)] $*"
    printf '%s\n' "$line" >&2
    # Never let a broken log destination abort the run mid-flight.
    printf '%s\n' "$line" >>"$LOGFILE" 2>/dev/null || true
}

err() {
    log "ERROR: $1"
    exit "${2:-1}"
}

usage() {
    # Print only the delimited block above - never `grep '^#' "$0"`, which
    # also prints the shebang, the author line and every section divider.
    sed -n '/^#---BEGIN-USAGE/,/^#---END-USAGE/{ /^#---/d; s/^# \?//; p; }' "$0"
    exit "${1:-0}"
}

cleanup() {
    local rc=$?
    if [[ $LOCK_HELD -eq 1 ]]; then
        rmdir "$LOCKDIR" 2>/dev/null || true
    fi
    exit "$rc"
}
trap cleanup EXIT

# Argument parsing
while [[ $# -gt 0 ]]; do
    case "$1" in
        -n) DAYS="$2"; shift 2 ;;
        -p) PATTERN="$2"; shift 2 ;;
        -d) DRY_RUN=1; shift ;;
        -h) usage 0 ;;
        -*) err "Unknown argument: $1" ;;
        *) break ;;
    esac
done

[[ $# -eq 1 ]] || usage 1
TARGET="$1"

# Prove the log destination works before doing any work.
mkdir -p "$(dirname "$LOGFILE")" 2>/dev/null || true
if ! { : >>"$LOGFILE"; } 2>/dev/null; then
    printf 'FATAL: cannot write log file %s\n' "$LOGFILE" >&2
    exit 4
fi

# Validate
[[ -d "$TARGET" ]] || err "Directory not found: $TARGET" 2
[[ -r "$TARGET" && -x "$TARGET" ]] || err "Permission denied: $TARGET" 3

# Single instance
if mkdir "$LOCKDIR" 2>/dev/null; then
    LOCK_HELD=1
else
    err "Another instance is running (lock: $LOCKDIR)" 5
fi

# Main
main() {
    log "Starting cleanup of $TARGET (older than $DAYS days, skipping $PATTERN)"

    local count=0
    while IFS= read -r -d '' file; do
        if [[ $DRY_RUN -eq 1 ]]; then
            log "DRY-RUN would delete: $file"
        else
            log "Deleting: $file"
            rm -f "$file"
        fi
        count=$((count + 1))
    done < <(find "$TARGET" -type f -mtime +"$DAYS" ! -name "$PATTERN" -print0)

    if [[ $DRY_RUN -eq 1 ]]; then
        log "Dry run complete. $count files would be deleted."
    else
        log "Done. Deleted $count files."
    fi
}

main "$@"

Task 3: Make it executable

chmod +x cleanup-logs.sh

Task 4: Lint with ShellCheck

shellcheck cleanup-logs.sh

Should be clean (or with only minor warnings). ShellCheck exiting 0 is necessary, not sufficient - Task 5 is what proves the script works.

Task 5: Test the happy path

Dry run first. Always dry run first with a tool that deletes.

# Create test files
mkdir -p /tmp/test-cleanup
touch -t 202501010000 /tmp/test-cleanup/old.log
touch /tmp/test-cleanup/new.log
touch -t 202501010000 /tmp/test-cleanup/old.log.gz

# Preview
./cleanup-logs.sh -d /tmp/test-cleanup
Read-only / Safe
$ ./cleanup-logs.sh -d /tmp/test-cleanup; echo exit=$?
[2026-08-11T04:39:53+00:00] Starting cleanup of /tmp/test-cleanup (older than 30 days, skipping *.gz)
[2026-08-11T04:39:53+00:00] DRY-RUN would delete: /tmp/test-cleanup/old.log
[2026-08-11T04:39:53+00:00] Dry run complete. 1 files would be deleted.
exit=0

Then run it for real:

Destructive
$ ./cleanup-logs.sh /tmp/test-cleanup; echo exit=$?; ls /tmp/test-cleanup
[2026-08-11T04:39:53+00:00] Starting cleanup of /tmp/test-cleanup (older than 30 days, skipping *.gz)
[2026-08-11T04:39:53+00:00] Deleting: /tmp/test-cleanup/old.log
[2026-08-11T04:39:53+00:00] Done. Deleted 1 files.
exit=0
new.log
old.log.gz

Check the log file was written as well as stderr:

tail -3 "${XDG_STATE_HOME:-$HOME/.local/state}/cleanup-logs.log"

Task 6: Test edge cases

Every case below has an exit code you should assert. Check it with echo $? after each run - the exit code is the contract a cron job or CI runner reads, not the message.

# No arguments - prints usage, exit 1
./cleanup-logs.sh; echo "exit=$?"

# Help - prints the same usage, exit 0
./cleanup-logs.sh -h; echo "exit=$?"

# Unknown option - exit 1
./cleanup-logs.sh -z /tmp/test-cleanup; echo "exit=$?"

# Non-existent directory - exit 2
./cleanup-logs.sh /nonexistent; echo "exit=$?"

# Unreadable directory - exit 3
mkdir -p /tmp/no-access
chmod 000 /tmp/no-access
./cleanup-logs.sh /tmp/no-access; echo "exit=$?"
chmod 700 /tmp/no-access && rmdir /tmp/no-access

# Unwritable log destination - exit 4, and nothing is deleted
LOGFILE=/var/log/cleanup-logs.log ./cleanup-logs.sh /tmp/test-cleanup; echo "exit=$?"

# Custom age and pattern
./cleanup-logs.sh -n 7 -p "*.tmp" /tmp/test-cleanup; echo "exit=$?"

Task 7: Document

SCRIPT: cleanup-logs.sh
PURPOSE: Delete old log files
ARGUMENTS:
  <directory>    Required, target directory
OPTIONS:
  -n DAYS        Age threshold (default 30)
  -p PATTERN     Skip pattern (default *.gz)
  -d             Dry run
ENVIRONMENT:
  LOGFILE        Log destination, default under $XDG_STATE_HOME
EXIT CODES:
  0    Success
  1    Invalid arguments
  2    Directory not found
  3    Permission denied
  4    Log file not writable
  5    Another instance is running
ERROR HANDLING:
  set -euo pipefail
  trap cleanup EXIT (releases the lock on every exit path)
  Log destination proven at startup, then non-fatal
  Functions: log, err, usage, cleanup
TESTS PASSED (exit code asserted for each):
  - Dry run                     0
  - Happy path                  0
  - No arguments                1
  - Unknown option              1
  - Non-existent directory      2
  - Permission denied           3
  - Unwritable log file         4
  - Custom options              0
SHELLCHECK: clean

Validation

The lab is complete when all of the following hold, run as a normal unprivileged user:

Read-only / Safe
$ shellcheck -s bash cleanup-logs.sh; echo exit=$?
exit=0
InvocationExpected exitExpected effect
./cleanup-logs.sh -d /tmp/test-cleanup0Reports old.log, deletes nothing
./cleanup-logs.sh /tmp/test-cleanup0ls shows only new.log and old.log.gz
./cleanup-logs.sh1Usage printed, no files touched
./cleanup-logs.sh -h0Usage printed
./cleanup-logs.sh -z DIR1ERROR: Unknown argument: -z
./cleanup-logs.sh /nonexistent2ERROR: Directory not found
./cleanup-logs.sh /tmp/no-access (mode 000)3ERROR: Permission denied
LOGFILE=/var/log/... ./cleanup-logs.sh DIR4FATAL: cannot write log file, no deletions

Also confirm the lock releases: after any run, including the failing ones, ls -d "${TMPDIR:-/tmp}/cleanup-logs.lock" must report that the directory does not exist.

Deliverables

  • · A production-grade shell script
  • · ShellCheck-clean output
  • · Test cases

Verification status

Last reviewed
2026-08-09
Executed end to end
not yet run on hardware

The commands and configuration here have been reviewed against the verified software versions, but nobody has run this lab start to finish on a system meeting its prerequisites. Treat the Expected Outcome as the intended result rather than an observed one, and keep the Cleanup section to hand.