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 "$@"