#!/usr/bin/env bash
# ==============================================================================
# Script Name: comprehensive_backup_v3.sh
# Description: Robust tar+zstd backup with pre-checks, progress (pv), optional
# buffering (mbuffer), exclusions, and detailed exit-code handling.
# Usage: sudo bash comprehensive_backup_v3.sh
# Requirements: bash, tar, zstd, du, df, awk, numfmt, stat
# Optional: pv, mbuffer, ionice
# ==============================================================================
set -o pipefail
umask 077
IFS=$'\n\t'
# --- Configuration defaults ---
DEFAULT_SOURCE_DIR="/media/usb0"
DEFAULT_DEST_DIR="/media/usb5"
DEFAULT_FILENAME_BASE="backup_usb0"
DEFAULT_COMPRESSION_LEVEL=9 # 1–19, higher = better compression, slower
DEFAULT_USE_PV="y"
DEFAULT_USE_MBUFFER="y"
DEFAULT_MBUFFER_SIZE="6G" # e.g., 128M, 512M, 1G, 6G
DEFAULT_EXCLUDE_PATTERNS=() # e.g., ('./cache/*' '*.tmp')
# --- Helper functions ---
print_separator() {
printf '%0.s-' {1..70}
printf '\n'
}
die() {
printf 'Error: %s\n' "$*" >&2
exit 1
}
warn() {
printf 'Warning: %s\n' "$*" >&2
}
info() {
printf '%s\n' "$*"
}
check_command() {
if ! command -v "$1" >/dev/null 2>&1; then
die "Required command '$1' not found. Install it (e.g., 'sudo apt install $1')."
fi
}
read_with_default() {
# $1 = prompt, $2 = default
local prompt default reply
prompt=$1
default=$2
read -r -p "$prompt" reply
if [[ -z $reply ]]; then
reply=$default
fi
printf '%s' "$reply"
}
confirm_yes() {
# Prompt user; return 0 if yes, 1 otherwise
local prompt reply
prompt=$1
read -r -p "$prompt" reply
reply=${reply:-n}
case "${reply,,}" in
y|yes) return 0 ;;
*) return 1 ;;
esac
}
# --- Root check ---
if [[ $EUID -ne 0 ]]; then
die "This script must be run with sudo/root privileges."
fi
START_TIME=$(date +%s)
on_exit() {
local exit_code=$?
local end_time elapsed h m s
end_time=$(date +%s)
elapsed=$((end_time - START_TIME))
h=$((elapsed / 3600))
m=$(((elapsed % 3600) / 60))
s=$((elapsed % 60))
print_separator
printf 'Total runtime: %02d:%02d:%02d (exit code %d)\n' "$h" "$m" "$s" "$exit_code"
}
trap on_exit EXIT
# --- Dependency checks ---
print_separator
info "Checking required commands..."
check_command tar
check_command zstd
check_command du
check_command df
check_command awk
check_command numfmt
check_command stat
# Optional tools checked only if used later
OPTIONAL_PV_PRESENT=0
OPTIONAL_MBUFFER_PRESENT=0
if command -v pv >/dev/null 2>&1; then
OPTIONAL_PV_PRESENT=1
fi
if command -v mbuffer >/dev/null 2>&1; then
OPTIONAL_MBUFFER_PRESENT=1
fi
info "Core commands available."
# --- Interactive configuration ---
print_separator
info "Configure backup parameters (press Enter for defaults)."
SOURCE_DIR=$(read_with_default "Source directory [${DEFAULT_SOURCE_DIR}]: " "$DEFAULT_SOURCE_DIR")
DEST_DIR=$(read_with_default "Destination directory [${DEFAULT_DEST_DIR}]: " "$DEFAULT_DEST_DIR")
FILENAME_BASE=$(read_with_default "Base filename for archive [${DEFAULT_FILENAME_BASE}]: " "$DEFAULT_FILENAME_BASE")
COMPRESSION_LEVEL=$(read_with_default "Compression level (1=fast,19=best,default=${DEFAULT_COMPRESSION_LEVEL}): " "$DEFAULT_COMPRESSION_LEVEL")
if ! [[ "$COMPRESSION_LEVEL" =~ ^[1-9]$|^1[0-9]$ ]]; then
warn "Invalid compression level '$COMPRESSION_LEVEL'; using default ${DEFAULT_COMPRESSION_LEVEL}."
COMPRESSION_LEVEL=$DEFAULT_COMPRESSION_LEVEL
fi
USE_PV=$(read_with_default "Use 'pv' for progress monitoring? (Y/n) [${DEFAULT_USE_PV}]: " "$DEFAULT_USE_PV")
case "${USE_PV,,}" in
y|yes) USE_PV="y" ;;
n|no) USE_PV="n" ;;
*) USE_PV="$DEFAULT_USE_PV" ;;
esac
USE_MBUFFER=$(read_with_default "Use 'mbuffer' for I/O buffering? (Y/n) [${DEFAULT_USE_MBUFFER}]: " "$DEFAULT_USE_MBUFFER")
case "${USE_MBUFFER,,}" in
y|yes) USE_MBUFFER="y" ;;
n|no) USE_MBUFFER="n" ;;
*) USE_MBUFFER="$DEFAULT_USE_MBUFFER" ;;
esac
MBUFFER_SIZE=$DEFAULT_MBUFFER_SIZE
if [[ "$USE_MBUFFER" == "y" ]]; then
if (( OPTIONAL_MBUFFER_PRESENT == 0 )); then
warn "'mbuffer' not found; disabling mbuffer usage."
USE_MBUFFER="n"
else
MBUFFER_SIZE=$(read_with_default "mbuffer size (e.g., 256M, 1G) [${DEFAULT_MBUFFER_SIZE}]: " "$DEFAULT_MBUFFER_SIZE")
fi
fi
if [[ "$USE_PV" == "y" && $OPTIONAL_PV_PRESENT -eq 0 ]]; then
warn "'pv' not found; disabling pv usage."
USE_PV="n"
fi
# Exclude patterns input
info "Enter exclude patterns one per line (relative to source, e.g., './cache/*', '*.tmp')."
info "Press Enter on an empty line to finish."
EXCLUDE_PATTERNS=()
while :; do
read -r -p "Exclude pattern (or Enter to finish): " pattern
if [[ -z $pattern ]]; then
break
fi
EXCLUDE_PATTERNS+=("$pattern")
done
if ((${#EXCLUDE_PATTERNS[@]} == 0)); then
EXCLUDE_PATTERNS=("${DEFAULT_EXCLUDE_PATTERNS[@]}")
fi
# --- Pre-checks ---
print_separator
info "Performing pre-checks..."
# Directory checks
[[ -d $SOURCE_DIR ]] || die "Source directory '$SOURCE_DIR' does not exist or is not a directory."
[[ -d $DEST_DIR ]] || die "Destination directory '$DEST_DIR' does not exist or is not a directory."
if ! mountpoint -q "$SOURCE_DIR"; then
warn "Source directory '$SOURCE_DIR' is not a dedicated mount point."
fi
if ! mountpoint -q "$DEST_DIR"; then
warn "Destination directory '$DEST_DIR' is not a dedicated mount point."
fi
# Source size
info "Calculating source size (du -sb)..."
if ! SRC_SIZE_LINE=$(du -sb -- "$SOURCE_DIR" 2>/dev/null); then
die "Could not determine source size with du -sb '$SOURCE_DIR'."
fi
SRC_SIZE_BYTES=$(awk '{print $1}' <<<"$SRC_SIZE_LINE")
if [[ -z $SRC_SIZE_BYTES ]]; then
die "Failed to parse source size from du output."
fi
SRC_SIZE_HUMAN=$(numfmt --to=iec "$SRC_SIZE_BYTES")
info "Source size: ${SRC_SIZE_HUMAN} (${SRC_SIZE_BYTES} bytes)"
if (( SRC_SIZE_BYTES <= 4096 )); then
warn "Source directory size is very small; it may be effectively empty."
fi
# Destination free space
if ! DEST_AVAIL_BYTES=$(df --output=avail -B1 -- "$DEST_DIR" 2>/dev/null | awk 'NR==2{print $1}'); then
die "Could not determine destination available space for '$DEST_DIR'."
fi
if [[ -z $DEST_AVAIL_BYTES ]]; then
die "Failed to parse destination available space."
fi
DEST_AVAIL_HUMAN=$(numfmt --to=iec "$DEST_AVAIL_BYTES")
info "Destination available space: ${DEST_AVAIL_HUMAN} (${DEST_AVAIL_BYTES} bytes)"
if (( SRC_SIZE_BYTES > DEST_AVAIL_BYTES )); then
print_separator
warn "Source size (${SRC_SIZE_HUMAN}) is larger than destination free space (${DEST_AVAIL_HUMAN})."
warn "Backup will only succeed if compression reduces data below available space."
elif (( DEST_AVAIL_BYTES < SRC_SIZE_BYTES / 2 )); then
warn "Destination free space is less than half the source size; ensure compression is effective."
fi
# Destination file path
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
DEST_FILENAME="${FILENAME_BASE}_L${COMPRESSION_LEVEL}_${TIMESTAMP}.tar.zst"
DEST_FILE_PATH="${DEST_DIR%/}/$DEST_FILENAME"
if [[ -e $DEST_FILE_PATH ]]; then
if confirm_yes "Destination file '$DEST_FILE_PATH' exists. Overwrite? (y/N): "; then
warn "Overwriting existing file."
else
info "Aborting."
exit 0
fi
fi
# --- Summary and confirmation ---
print_separator
info "Backup summary:"
info " Source: $SOURCE_DIR ($SRC_SIZE_HUMAN)"
info " Destination: $DEST_FILE_PATH"
info " Compression: zstd level $COMPRESSION_LEVEL"
PROGRESS_STR="pv disabled"
[[ $USE_PV == y ]] && PROGRESS_STR="pv enabled"
BUFFER_STR="none"
[[ $USE_MBUFFER == y ]] && BUFFER_STR="mbuffer ($MBUFFER_SIZE)"
info " Progress: $PROGRESS_STR"
info " IO buffer: $BUFFER_STR"
if ((${#EXCLUDE_PATTERNS[@]} > 0)); then
info " Exclusions: ${EXCLUDE_PATTERNS[*]}"
fi
print_separator
if ! confirm_yes "Proceed with backup? (y/N): "; then
info "Aborted by user."
exit 0
fi
# Optionally lower priority for the backup process
if confirm_yes "Lower CPU/IO priority for backup (recommended on busy systems)? (y/N): "; then
if command -v ionice >/dev/null 2>&1; then
ionice -c3 -p "$$" >/dev/null 2>&1 || warn "ionice failed to adjust I/O priority."
fi
renice 10 -p "$$" >/dev/null 2>&1 || true
fi
# --- Build tar command array ---
TAR_CMD=(tar -cf -)
for pattern in "${EXCLUDE_PATTERNS[@]}"; do
TAR_CMD+=(--exclude="$pattern")
done
TAR_CMD+=(-C "$SOURCE_DIR" .)
# --- Execute backup pipeline ---
print_separator
PIPE_DESC="tar | "
[[ $USE_PV == y ]] && PIPE_DESC+="pv | "
[[ $USE_MBUFFER == y ]] && PIPE_DESC+="mbuffer | "
PIPE_DESC+="zstd"
info "Starting backup process..."
info "Running $PIPE_DESC ..."
print_separator
PIPE_STATUS=()
EXECUTION_EXIT_CODE=0
run_pipeline() {
if [[ $USE_PV == y && $USE_MBUFFER == y ]]; then
"${TAR_CMD[@]}" \
| pv -s "$SRC_SIZE_BYTES" \
| mbuffer -m "$MBUFFER_SIZE" \
| zstd -T0 "-${COMPRESSION_LEVEL}" -o "$DEST_FILE_PATH"
elif [[ $USE_PV == y && $USE_MBUFFER != y ]]; then
"${TAR_CMD[@]}" \
| pv -s "$SRC_SIZE_BYTES" \
| zstd -T0 "-${COMPRESSION_LEVEL}" -o "$DEST_FILE_PATH"
elif [[ $USE_PV != y && $USE_MBUFFER == y ]]; then
"${TAR_CMD[@]}" \
| mbuffer -m "$MBUFFER_SIZE" \
| zstd -T0 "-${COMPRESSION_LEVEL}" -o "$DEST_FILE_PATH"
else
"${TAR_CMD[@]}" \
| zstd -T0 "-${COMPRESSION_LEVEL}" -o "$DEST_FILE_PATH"
fi
}
run_pipeline
EXECUTION_EXIT_CODE=$?
PIPE_STATUS=("${PIPESTATUS[@]}")
print_separator
info "Backup process finished."
info "Checking pipeline exit codes: ${PIPE_STATUS[*]:-none}"
STAGE_NAMES=("tar")
if [[ $USE_PV == y ]]; then STAGE_NAMES+=("pv"); fi
if [[ $USE_MBUFFER == y ]]; then STAGE_NAMES+=("mbuffer"); fi
STAGE_NAMES+=("zstd")
FINAL_EXIT_CODE=0
for i in "${!PIPE_STATUS[@]}"; do
stage_name=${STAGE_NAMES[$i]:-stage_$i}
stage_status=${PIPE_STATUS[$i]}
if [[ $stage_status -ne 0 ]]; then
printf 'Error: Stage %s failed with exit code %d\n' "$stage_name" "$stage_status" >&2
if [[ $FINAL_EXIT_CODE -eq 0 ]]; then
FINAL_EXIT_CODE=$stage_status
fi
fi
done
if [[ $FINAL_EXIT_CODE -eq 0 && $EXECUTION_EXIT_CODE -ne 0 ]]; then
warn "Overall pipeline exit code is $EXECUTION_EXIT_CODE despite all stages reporting success."
FINAL_EXIT_CODE=$EXECUTION_EXIT_CODE
fi
if [[ $FINAL_EXIT_CODE -ne 0 ]]; then
printf 'Backup FAILED (exit code %d). Archive may be incomplete: %s\n' "$FINAL_EXIT_CODE" "$DEST_FILE_PATH" >&2
exit "$FINAL_EXIT_CODE"
fi
info "Pipeline completed successfully."
# --- Post-execution: report size and optional verification ---
if ! COMPRESSED_SIZE_BYTES=$(stat -c%s -- "$DEST_FILE_PATH" 2>/dev/null); then
warn "Could not stat archive '$DEST_FILE_PATH'."
else
COMPRESSED_SIZE_HUMAN=$(numfmt --to=iec "$COMPRESSED_SIZE_BYTES")
info "Archive saved to: $DEST_FILE_PATH"
info "Compressed size: ${COMPRESSED_SIZE_HUMAN} (${COMPRESSED_SIZE_BYTES} bytes)"
fi
print_separator
info "Verification commands:"
info " zstd -t \"$DEST_FILE_PATH\""
info " tar --list -I zstd -f \"$DEST_FILE_PATH\" | less"
if confirm_yes "Run 'zstd -t' now to test archive integrity? (y/N): "; then
print_separator
info "Running: zstd -t \"$DEST_FILE_PATH\""
if zstd -t "$DEST_FILE_PATH"; then
info "Integrity test PASSED."
else
warn "Integrity test FAILED."
fi
fi
exit 0
URL: https://ib.bsb.br/backup-bash-script