Skip to content

Implementation — Backup Script

Build the script step by step. Start with rsync, then add archives and rotation.

Step 1 — Scaffold and Config

#!/usr/bin/env bash
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

# Defaults
DEST_DIR="${HOME}/backups"
KEEP=7
COMPRESS=false
SOURCES=()
LOGFILE="${DEST_DIR}/backup.log"

usage() {
    cat <<EOF
Usage: $(basename "$0") [OPTIONS] [COMMAND]

Commands:
  backup             Run a backup (default)
  restore [N]        Restore from backup N (1=newest)
  list               List available backups
  verify  [N]        Verify backup N integrity

Options:
  -s, --source DIR   Source directory (repeatable)
  -d, --dest DIR     Destination directory (default: ~/backups)
  -k, --keep N       Backups to keep (default: 7)
  -c, --compress     Also create a compressed tar.gz archive
  -h, --help         Show this help
EOF
}

Step 2 — Argument Parsing

COMMAND="backup"

while [[ "${1:-}" =~ ^- ]]; do
    case "$1" in
        -s|--source)   SOURCES+=("${2:?--source requires a path}"); shift ;;
        -d|--dest)     DEST_DIR="${2:?--dest requires a path}"; shift ;;
        -k|--keep)     KEEP="${2:?--keep requires a number}"; shift ;;
        -c|--compress) COMPRESS=true ;;
        -h|--help)     usage; exit 0 ;;
        *) echo "Unknown option: $1" >&2; usage; exit 1 ;;
    esac
    shift
done

COMMAND="${1:-backup}"
LOGFILE="${DEST_DIR}/backup.log"

Step 3 — Logging

_log() {
    local level="$1"; shift
    local msg="[$(date '+%Y-%m-%d %H:%M:%S')] [$level] $*"
    mkdir -p "$DEST_DIR"
    echo "$msg" | tee -a "$LOGFILE"
}
log_info()  { _log "INFO " "$@"; }
log_warn()  { _log "WARN " "$@"; }
log_error() { _log "ERROR" "$@"; }

Step 4 — rsync Backup

TIMESTAMP=$(date '+%Y-%m-%d_%H%M%S')

do_backup() {
    [[ "${#SOURCES[@]}" -eq 0 ]] && {
        echo "Error: at least one --source required" >&2; exit 1
    }

    local backup_dir="${DEST_DIR}/${TIMESTAMP}"
    mkdir -p "$backup_dir"
    log_info "=== Backup started → $backup_dir ==="

    local failed=0
    for src in "${SOURCES[@]}"; do
        [[ -d "$src" ]] || { log_error "Source not found: $src"; (( failed++ )) || true; continue; }

        # Derive a safe directory name from the source path
        local dest_name
        dest_name="$(echo "$src" | tr '/' '_' | sed 's/^_//')"
        local dest="${backup_dir}/${dest_name}"

        log_info "rsync: $src$dest"
        if rsync -a --delete -- "$src/" "$dest/"; then
            log_info "rsync OK: $src"
        else
            log_error "rsync FAILED: $src"
            (( failed++ )) || true
        fi
    done

    if [[ "$COMPRESS" == true ]]; then
        local archive="${backup_dir}.tar.gz"
        log_info "compressing → $archive"
        tar -czf "$archive" -C "$DEST_DIR" "$TIMESTAMP/"
        sha256sum "$archive" > "${archive}.sha256"
        log_info "checksum written: ${archive}.sha256"
    fi

    log_info "=== Backup complete. Failed sources: $failed ==="
    return $failed
}

Step 5 — Rotation

rotate_backups() {
    log_info "Rotating backups — keeping last $KEEP"

    # List backup directories, sorted oldest-first
    local dirs
    mapfile -t dirs < <(find "$DEST_DIR" -maxdepth 1 -mindepth 1 -type d \
                         -name '[0-9][0-9][0-9][0-9]-*' | sort)

    local count="${#dirs[@]}"
    if (( count > KEEP )); then
        local to_delete=$(( count - KEEP ))
        for (( i=0; i<to_delete; i++ )); do
            log_info "removing old backup: ${dirs[$i]}"
            rm -rf -- "${dirs[$i]}"
            # Also remove tar.gz if it exists
            rm -f -- "${dirs[$i]}.tar.gz" "${dirs[$i]}.tar.gz.sha256"
        done
        log_info "Removed $to_delete old backup(s)"
    else
        log_info "No rotation needed ($count/$KEEP slots used)"
    fi
}

rm -rf in rotation code

Double-check that the glob pattern [0-9][0-9][0-9][0-9]-* is specific enough. A bug here could delete other directories in DEST_DIR. The pattern matches only directories starting with a four-digit year, which is safe.

Step 6 — List and Verify

list_backups() {
    echo "Available backups in $DEST_DIR:"
    local i=1
    while IFS= read -r dir; do
        local size
        size=$(du -sh "$dir" 2>/dev/null | cut -f1)
        printf "  %-3d  %-22s  (%s)\n" "$i" "$(basename "$dir")" "$size"
        (( i++ ))
    done < <(find "$DEST_DIR" -maxdepth 1 -mindepth 1 -type d \
              -name '[0-9][0-9][0-9][0-9]-*' | sort -r)
}

verify_backup() {
    local n="${1:-1}"
    local dir
    dir=$(find "$DEST_DIR" -maxdepth 1 -mindepth 1 -type d \
           -name '[0-9][0-9][0-9][0-9]-*' | sort -r | sed -n "${n}p")

    [[ -z "$dir" ]] && { echo "No backup #$n found"; exit 1; }

    echo "Verifying backup: $(basename "$dir")"
    local archive="${dir}.tar.gz"
    local checksum="${archive}.sha256"

    if [[ -f "$checksum" ]]; then
        if sha256sum --check "$checksum"; then
            echo "Checksum OK"
        else
            echo "Checksum FAILED" >&2; exit 1
        fi
    else
        echo "No archive found — checking rsync backup integrity"
        find "$dir" -type f | wc -l | xargs -I{} echo "  {} files present"
    fi
}

Step 7 — Restore

do_restore() {
    local n="${1:-1}"
    local dir
    dir=$(find "$DEST_DIR" -maxdepth 1 -mindepth 1 -type d \
           -name '[0-9][0-9][0-9][0-9]-*' | sort -r | sed -n "${n}p")

    [[ -z "$dir" ]] && { echo "No backup #$n found"; exit 1; }

    echo "Restore from: $(basename "$dir")"
    echo "This will overwrite files in the original source locations."
    read -r -p "Continue? [y/N] " confirm
    [[ "${confirm,,}" == "y" ]] || { echo "Aborted"; exit 0; }

    for src_backup in "$dir"/*/; do
        local dest_name
        dest_name="$(basename "$src_backup")"
        # Reverse the path encoding: underscores back to slashes, prepend /
        local restore_path="/${dest_name//_/\/}"
        log_info "restoring $src_backup$restore_path"
        rsync -a -- "$src_backup/" "$restore_path/"
    done

    log_info "Restore complete from $(basename "$dir")"
}

Step 8 — main()

main() {
    case "$COMMAND" in
        backup)
            do_backup
            rotate_backups
            ;;
        list)
            list_backups
            ;;
        verify)
            verify_backup "${2:-1}"
            ;;
        restore)
            do_restore "${2:-1}"
            ;;
        *)
            echo "Unknown command: $COMMAND" >&2; usage; exit 1
            ;;
    esac
}

[[ "${BASH_SOURCE[0]}" == "$0" ]] && main "$@"

setup | testing