#!/usr/bin/env bash ######################################################################################### #### backup-verify.sh — Verify backup files exist, are recent, and aren't zero-size #### #### Checks file existence, size, and modification time against thresholds #### #### #### #### Author: Phil Connor #### #### Contact: contact@mylinux.work #### #### License: MIT #### #### Version 1.00 #### #### #### #### Usage: #### #### ./backup-verify.sh /var/backups/*.tar.gz #### #### ./backup-verify.sh --config backup-paths.conf #### #### #### #### See --help for all options. #### ######################################################################################### set -euo pipefail # ── Defaults ────────────────────────────────────────────────────────── BACKUP_MAX_AGE="${BACKUP_MAX_AGE:-24h}" BACKUP_MIN_SIZE="${BACKUP_MIN_SIZE:-1}" VERBOSE="${VERBOSE:-false}" COLOR="${COLOR:-auto}" CONFIG_FILE="" PATH_FILE="" # ── State ───────────────────────────────────────────────────────────── SCRIPT_NAME="$(basename "$0")" readonly SCRIPT_NAME ENTRIES=() COUNT_TOTAL=0 COUNT_OK=0 COUNT_WARNING=0 COUNT_CRITICAL=0 # ── Colors ──────────────────────────────────────────────────────────── setup_colors() { if [[ "$COLOR" == "never" ]]; then RED="" GREEN="" YELLOW="" CYAN="" BOLD="" DIM="" RESET="" return fi if [[ "$COLOR" == "always" ]] || [[ -t 1 ]]; then RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m' CYAN='\033[0;36m' BOLD='\033[1m' DIM='\033[2m' RESET='\033[0m' else RED="" GREEN="" YELLOW="" CYAN="" BOLD="" DIM="" RESET="" fi } # ── Logging ─────────────────────────────────────────────────────────── log() { echo -e "${CYAN}[INFO]${RESET} $*"; } warn() { echo -e "${YELLOW}[WARN]${RESET} $*" >&2; } err() { echo -e "${RED}[ERROR]${RESET} $*" >&2; } verbose() { if [[ "$VERBOSE" == "true" ]]; then echo -e "${DIM}[DEBUG]${RESET} $*"; fi; } # ── Helpers ─────────────────────────────────────────────────────────── section_header() { echo "" echo -e " ${BOLD}${CYAN}── $1 ──${RESET}" echo "" } field() { printf " ${BOLD}%-22s${RESET} %s\n" "$1" "$2" } field_color() { printf " ${BOLD}%-22s${RESET} %b\n" "$1" "$2" } human_bytes() { local bytes="$1" if [[ "$bytes" -ge 1073741824 ]]; then awk "BEGIN { printf \"%.1f GiB\", $bytes / 1073741824 }" elif [[ "$bytes" -ge 1048576 ]]; then awk "BEGIN { printf \"%.1f MiB\", $bytes / 1048576 }" elif [[ "$bytes" -ge 1024 ]]; then awk "BEGIN { printf \"%.1f KiB\", $bytes / 1024 }" else echo "${bytes} B" fi } human_age() { local seconds="$1" local days=$((seconds / 86400)) local hours=$(( (seconds % 86400) / 3600 )) local mins=$(( (seconds % 3600) / 60 )) if [[ "$days" -gt 0 ]]; then echo "${days}d ${hours}h" elif [[ "$hours" -gt 0 ]]; then echo "${hours}h ${mins}m" else echo "${mins}m" fi } # Convert age string (24h, 7d, 2w) to seconds parse_age_to_seconds() { local age="$1" local num="${age%[hdw]*}" local unit="${age##*[0-9]}" case "$unit" in h) echo $((num * 3600)) ;; d) echo $((num * 86400)) ;; w) echo $((num * 604800)) ;; *) echo $((num * 3600)) ;; esac } # Convert size string (1, 1K, 1M, 1G) to bytes parse_size_to_bytes() { local size="$1" # Pure number if [[ "$size" =~ ^[0-9]+$ ]]; then echo "$size" return fi local num="${size%[KkMmGg]*}" local unit="${size##*[0-9]}" case "${unit^^}" in K) echo $((num * 1024)) ;; M) echo $((num * 1048576)) ;; G) echo $((num * 1073741824)) ;; *) echo "$num" ;; esac } # ══════════════════════════════════════════════════════════════════════ # VERIFICATION # ══════════════════════════════════════════════════════════════════════ verify_file() { local file="$1" local max_age_str="$2" local min_size_str="$3" local max_age_secs min_size_bytes max_age_secs=$(parse_age_to_seconds "$max_age_str") min_size_bytes=$(parse_size_to_bytes "$min_size_str") COUNT_TOTAL=$((COUNT_TOTAL + 1)) verbose "Checking: ${file} (max-age=${max_age_str}, min-size=${min_size_str})" # Check existence if [[ ! -e "$file" ]]; then printf " %b%-50s %10s %10s %s%b\n" "$RED" "$file" "--" "--" "MISSING" "$RESET" COUNT_CRITICAL=$((COUNT_CRITICAL + 1)) return fi # Get file info local file_size file_mtime now_epoch age_secs file_size=$(stat -c%s "$file" 2>/dev/null || echo "0") file_mtime=$(stat -c%Y "$file" 2>/dev/null || echo "0") now_epoch=$(date +%s) age_secs=$((now_epoch - file_mtime)) local size_str age_str size_str=$(human_bytes "$file_size") age_str=$(human_age "$age_secs") # Check zero-size if [[ "$file_size" -eq 0 ]]; then printf " %b%-50s %10s %10s %s%b\n" "$RED" "$file" "$size_str" "$age_str" "EMPTY" "$RESET" COUNT_CRITICAL=$((COUNT_CRITICAL + 1)) return fi # Check minimum size if [[ "$file_size" -lt "$min_size_bytes" ]]; then printf " %b%-50s %10s %10s %s%b\n" "$YELLOW" "$file" "$size_str" "$age_str" "SMALL" "$RESET" COUNT_WARNING=$((COUNT_WARNING + 1)) return fi # Check age if [[ "$age_secs" -gt "$max_age_secs" ]]; then printf " %b%-50s %10s %10s %s%b\n" "$YELLOW" "$file" "$size_str" "$age_str" "STALE" "$RESET" COUNT_WARNING=$((COUNT_WARNING + 1)) return fi # All good printf " %b%-50s %10s %10s %s%b\n" "$GREEN" "$file" "$size_str" "$age_str" "OK" "$RESET" COUNT_OK=$((COUNT_OK + 1)) } verify_glob() { local pattern="$1" local max_age_str="$2" local min_size_str="$3" local found=false # Use compgen to safely expand globs local files files=$(compgen -G "$pattern" 2>/dev/null || true) if [[ -z "$files" ]]; then COUNT_TOTAL=$((COUNT_TOTAL + 1)) COUNT_CRITICAL=$((COUNT_CRITICAL + 1)) printf " %b%-50s %10s %10s %s%b\n" "$RED" "$pattern" "--" "--" "MISSING" "$RESET" return fi while IFS= read -r file; do found=true verify_file "$file" "$max_age_str" "$min_size_str" done <<< "$files" if [[ "$found" == "false" ]]; then COUNT_TOTAL=$((COUNT_TOTAL + 1)) COUNT_CRITICAL=$((COUNT_CRITICAL + 1)) printf " %b%-50s %10s %10s %s%b\n" "$RED" "$pattern" "--" "--" "MISSING" "$RESET" fi } # ══════════════════════════════════════════════════════════════════════ # INPUT PARSING # ══════════════════════════════════════════════════════════════════════ add_entry() { local path="$1" local max_age="${2:-$BACKUP_MAX_AGE}" local min_size="${3:-$BACKUP_MIN_SIZE}" ENTRIES+=("${path}|${max_age}|${min_size}") } load_config_file() { local file="$1" if [[ ! -f "$file" ]]; then err "Config file not found: $file" exit 1 fi while IFS= read -r line; do line=$(echo "$line" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') [[ -z "$line" || "$line" == \#* ]] && continue local path max_age path=$(echo "$line" | awk '{print $1}') max_age=$(echo "$line" | awk '{print $2}') if [[ -z "$max_age" ]]; then max_age="$BACKUP_MAX_AGE" fi add_entry "$path" "$max_age" done < "$file" } load_paths_from_file() { local file="$1" if [[ ! -f "$file" ]]; then err "File not found: $file" exit 1 fi while IFS= read -r line; do line=$(echo "$line" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') [[ -z "$line" || "$line" == \#* ]] && continue add_entry "$line" done < "$file" } load_paths_from_stdin() { while IFS= read -r line; do line=$(echo "$line" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') [[ -z "$line" || "$line" == \#* ]] && continue add_entry "$line" done } # ══════════════════════════════════════════════════════════════════════ # USAGE # ══════════════════════════════════════════════════════════════════════ usage() { cat <&2 exit 1 ;; *) add_entry "$1"; shift ;; esac done } # ══════════════════════════════════════════════════════════════════════ # MAIN # ══════════════════════════════════════════════════════════════════════ main() { parse_args "$@" setup_colors # Load from config file if [[ -n "$CONFIG_FILE" ]]; then load_config_file "$CONFIG_FILE" fi # Load from path file if [[ -n "$PATH_FILE" ]]; then load_paths_from_file "$PATH_FILE" fi # Load from stdin if no entries yet and stdin is not a terminal if [[ ${#ENTRIES[@]} -eq 0 ]] && ! [[ -t 0 ]]; then load_paths_from_stdin fi if [[ ${#ENTRIES[@]} -eq 0 ]]; then err "No backup paths specified" echo "Run ${SCRIPT_NAME} --help for usage" >&2 exit 1 fi echo "" echo -e "${BOLD}Backup Verification — $(hostname -f 2>/dev/null || hostname)${RESET}" echo -e "${DIM}$(date '+%Y-%m-%d %H:%M:%S %Z')${RESET}" echo -e "${DIM}Defaults: max-age=${BACKUP_MAX_AGE}, min-size=${BACKUP_MIN_SIZE}${RESET}" section_header "Backup Status" printf " ${BOLD}%-50s %10s %10s %s${RESET}\n" "FILE" "SIZE" "AGE" "STATUS" printf " %s\n" "$(printf '%.0s─' {1..85})" for entry in "${ENTRIES[@]}"; do local path max_age min_size path=$(echo "$entry" | cut -d'|' -f1) max_age=$(echo "$entry" | cut -d'|' -f2) min_size=$(echo "$entry" | cut -d'|' -f3) # Check if path contains glob characters if [[ "$path" == *\** || "$path" == *\?* || "$path" == *\[* ]]; then verify_glob "$path" "$max_age" "$min_size" else verify_file "$path" "$max_age" "$min_size" fi done section_header "Summary" field "Total checked:" "$COUNT_TOTAL" field_color "OK:" "${GREEN}${COUNT_OK}${RESET}" if [[ "$COUNT_WARNING" -gt 0 ]]; then field_color "Warnings:" "${YELLOW}${COUNT_WARNING}${RESET}" else field "Warnings:" "$COUNT_WARNING" fi if [[ "$COUNT_CRITICAL" -gt 0 ]]; then field_color "Critical:" "${RED}${COUNT_CRITICAL}${RESET}" else field "Critical:" "$COUNT_CRITICAL" fi echo "" # Exit with error code if any critical issues if [[ "$COUNT_CRITICAL" -gt 0 ]]; then return 2 elif [[ "$COUNT_WARNING" -gt 0 ]]; then return 1 fi } main "$@"