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
$ ./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=0Then run it for real:
$ ./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.gzCheck 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:
$ shellcheck -s bash cleanup-logs.sh; echo exit=$?exit=0| Invocation | Expected exit | Expected effect |
|---|---|---|
./cleanup-logs.sh -d /tmp/test-cleanup | 0 | Reports old.log, deletes nothing |
./cleanup-logs.sh /tmp/test-cleanup | 0 | ls shows only new.log and old.log.gz |
./cleanup-logs.sh | 1 | Usage printed, no files touched |
./cleanup-logs.sh -h | 0 | Usage printed |
./cleanup-logs.sh -z DIR | 1 | ERROR: Unknown argument: -z |
./cleanup-logs.sh /nonexistent | 2 | ERROR: Directory not found |
./cleanup-logs.sh /tmp/no-access (mode 000) | 3 | ERROR: Permission denied |
LOGFILE=/var/log/... ./cleanup-logs.sh DIR | 4 | FATAL: 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.