311 lines
10 KiB
Bash
311 lines
10 KiB
Bash
#!/bin/bash
|
|
set -euo pipefail
|
|
|
|
# ─── Color helpers ────────────────────────────────────────────────────────────
|
|
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
|
|
BLUE='\033[0;34m'; CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m'
|
|
|
|
info() { echo -e "${CYAN}[INFO]${NC} $*"; }
|
|
success() { echo -e "${GREEN}[OK]${NC} $*"; }
|
|
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
|
error() { echo -e "${RED}[ERR]${NC} $*" >&2; }
|
|
step() { echo -e "\n${BOLD}${BLUE}══ $* ${NC}"; }
|
|
|
|
# ─── Sudo Keepalive ───────────────────────────────────────────────────────────
|
|
ensure_sudo() {
|
|
if ! sudo -v 2>/dev/null; then
|
|
error "This script requires sudo access"
|
|
exit 1
|
|
fi
|
|
( while true; do sudo -n true 2>/dev/null; sleep 50; done ) &
|
|
SUDO_KEEPALIVE_PID=$!
|
|
}
|
|
|
|
# ─── OS Detection ─────────────────────────────────────────────────────────────
|
|
detect_os() {
|
|
if [ -f /etc/os-release ]; then
|
|
. /etc/os-release
|
|
OS_ID="${ID:-unknown}"
|
|
OS_ID_LIKE="${ID_LIKE:-}"
|
|
else
|
|
error "Cannot detect OS (no /etc/os-release found)"
|
|
exit 1
|
|
fi
|
|
|
|
case "$OS_ID" in
|
|
aosc) DISTRO="aosc" ;;
|
|
debian) DISTRO="debian" ;;
|
|
ubuntu) DISTRO="ubuntu" ;;
|
|
fedora) DISTRO="fedora" ;;
|
|
*)
|
|
case "$OS_ID_LIKE" in
|
|
*debian*) DISTRO="debian" ;;
|
|
*fedora*|*rhel*) DISTRO="fedora" ;;
|
|
*) error "Unsupported distro: $OS_ID"; exit 1 ;;
|
|
esac
|
|
;;
|
|
esac
|
|
info "Detected distro: ${BOLD}$DISTRO${NC} (ID=$OS_ID)"
|
|
}
|
|
|
|
# ─── Install Snapper ──────────────────────────────────────────────────────────
|
|
install_snapper() {
|
|
step "Installing Snapper"
|
|
|
|
if command -v snapper &>/dev/null; then
|
|
success "snapper already installed ($(snapper --version 2>&1 | head -1))"
|
|
return
|
|
fi
|
|
|
|
info "Installing snapper ..."
|
|
case "$DISTRO" in
|
|
aosc) sudo oma install -y snapper ;;
|
|
debian|ubuntu) sudo apt-get update -qq && sudo apt-get install -y snapper ;;
|
|
fedora) sudo dnf install -y snapper ;;
|
|
esac
|
|
success "snapper installed"
|
|
}
|
|
|
|
# ─── List Btrfs Subvolumes ────────────────────────────────────────────────────
|
|
list_btrfs_mounts() {
|
|
# Returns sorted list of mount points that are btrfs
|
|
# -l disables tree output to avoid box-drawing characters in paths
|
|
findmnt -t btrfs -n -l -o TARGET 2>/dev/null | sort
|
|
}
|
|
|
|
# ─── Select Subvolumes Interactively ─────────────────────────────────────────
|
|
select_subvolumes() {
|
|
step "Select Subvolumes to Snapshot"
|
|
|
|
local mounts
|
|
mapfile -t mounts < <(list_btrfs_mounts)
|
|
|
|
if [ ${#mounts[@]} -eq 0 ]; then
|
|
error "No btrfs subvolumes found mounted on this system"
|
|
exit 1
|
|
fi
|
|
|
|
echo "Mounted btrfs filesystems:"
|
|
echo ""
|
|
for i in "${!mounts[@]}"; do
|
|
local subvol device
|
|
subvol=$(findmnt -n -l -o OPTIONS "${mounts[$i]}" 2>/dev/null \
|
|
| tr ',' '\n' | grep '^subvol=' | head -1 | sed 's/subvol=//' || echo "(default)")
|
|
device=$(findmnt -n -l -o SOURCE "${mounts[$i]}" 2>/dev/null || echo "?")
|
|
printf " %2d) %-25s subvol=%-20s %s\n" \
|
|
"$((i+1))" "${mounts[$i]}" "$subvol" "$device"
|
|
done
|
|
echo ""
|
|
|
|
SELECTED_MOUNTS=()
|
|
read -rp "Enter numbers to snapshot (e.g. 1 2 3, or 'all'): " selection
|
|
|
|
if [ "$selection" = "all" ]; then
|
|
SELECTED_MOUNTS=("${mounts[@]}")
|
|
else
|
|
for num in $selection; do
|
|
if [[ "$num" =~ ^[0-9]+$ ]] && [ "$num" -ge 1 ] && [ "$num" -le "${#mounts[@]}" ]; then
|
|
SELECTED_MOUNTS+=("${mounts[$((num-1))]}")
|
|
else
|
|
warn "Invalid selection: $num, skipping"
|
|
fi
|
|
done
|
|
fi
|
|
|
|
if [ ${#SELECTED_MOUNTS[@]} -eq 0 ]; then
|
|
error "No valid subvolumes selected"
|
|
exit 1
|
|
fi
|
|
|
|
info "Selected: ${SELECTED_MOUNTS[*]}"
|
|
}
|
|
|
|
# ─── Derive Config Name from Mount Point ─────────────────────────────────────
|
|
config_name_from_mount() {
|
|
local mount="$1"
|
|
if [ "$mount" = "/" ]; then
|
|
echo "root"
|
|
else
|
|
# /home -> home, /var/data -> var-data
|
|
echo "${mount#/}" | tr '/' '-'
|
|
fi
|
|
}
|
|
|
|
# ─── Write Snapper Config ─────────────────────────────────────────────────────
|
|
write_snapper_config() {
|
|
local mount="$1"
|
|
local name="$2"
|
|
local config_file="/etc/snapper/configs/$name"
|
|
|
|
sudo tee "$config_file" > /dev/null << EOF
|
|
# snapper config for server (daily snapshots)
|
|
|
|
# subvolume to snapshot
|
|
SUBVOLUME="$mount"
|
|
|
|
# filesystem type
|
|
FSTYPE="btrfs"
|
|
|
|
# btrfs qgroup for space aware cleanup algorithms
|
|
QGROUP=""
|
|
|
|
# fraction or absolute size of the filesystem space snapshots may use
|
|
SPACE_LIMIT="0.3"
|
|
|
|
# fraction or absolute size of the filesystem space that should be free
|
|
FREE_LIMIT="0.15"
|
|
|
|
# users and groups allowed to work with config
|
|
ALLOW_USERS=""
|
|
ALLOW_GROUPS=""
|
|
|
|
# sync users and groups from ALLOW_USERS and ALLOW_GROUPS to .snapshots directory
|
|
SYNC_ACL="no"
|
|
|
|
# start comparing pre- and post-snapshot in background after creating post-snapshot
|
|
BACKGROUND_COMPARISON="yes"
|
|
|
|
# run daily number cleanup
|
|
NUMBER_CLEANUP="yes"
|
|
|
|
# minimum age in seconds before a snapshot may be deleted by cleanup
|
|
NUMBER_MIN_AGE="3600"
|
|
|
|
# keep at most this many numbered snapshots
|
|
NUMBER_LIMIT="20"
|
|
|
|
# keep at most this many important numbered snapshots
|
|
NUMBER_LIMIT_IMPORTANT="5"
|
|
|
|
# timeline snapshots (automatic snapshots)
|
|
TIMELINE_CREATE="yes"
|
|
|
|
# cleanup timeline snapshots after some time
|
|
TIMELINE_CLEANUP="yes"
|
|
|
|
# minimum age in seconds before timeline snapshots may be deleted
|
|
TIMELINE_MIN_AGE="3600"
|
|
|
|
# no hourly snapshots retained
|
|
TIMELINE_LIMIT_HOURLY="0"
|
|
|
|
# keep 14 daily snapshots (last 2 weeks)
|
|
TIMELINE_LIMIT_DAILY="14"
|
|
|
|
# keep 4 weekly snapshots (last month)
|
|
TIMELINE_LIMIT_WEEKLY="4"
|
|
|
|
# keep 6 monthly snapshots (last 6 months)
|
|
TIMELINE_LIMIT_MONTHLY="6"
|
|
|
|
# no quarterly snapshots
|
|
TIMELINE_LIMIT_QUARTERLY="0"
|
|
|
|
# keep 2 yearly snapshots
|
|
TIMELINE_LIMIT_YEARLY="2"
|
|
|
|
# cleanup empty pre-post pairs
|
|
EMPTY_PRE_POST_CLEANUP="yes"
|
|
|
|
# minimum age in seconds before empty pre-post pairs may be deleted
|
|
EMPTY_PRE_POST_MIN_AGE="3600"
|
|
EOF
|
|
}
|
|
|
|
# ─── Apply Snapper Config for a Mount Point ───────────────────────────────────
|
|
apply_snapper_config() {
|
|
local mount="$1"
|
|
local name
|
|
name=$(config_name_from_mount "$mount")
|
|
|
|
step "Configuring snapper for $mount (config: $name)"
|
|
|
|
# Check if config already exists
|
|
if sudo snapper list-configs 2>/dev/null | awk 'NR>1 {print $1}' | grep -qw "$name"; then
|
|
warn "Config '$name' already exists, overwriting policy settings ..."
|
|
write_snapper_config "$mount" "$name"
|
|
else
|
|
info "Creating snapper config '$name' for $mount ..."
|
|
sudo snapper -c "$name" create-config "$mount"
|
|
success "Config '$name' created"
|
|
info "Applying snapshot policy ..."
|
|
write_snapper_config "$mount" "$name"
|
|
fi
|
|
|
|
success "Policy applied to config '$name'"
|
|
}
|
|
|
|
# ─── Enable Snapper Systemd Timers ───────────────────────────────────────────
|
|
enable_timers() {
|
|
step "Enabling Snapper Timers"
|
|
|
|
local timers=("snapper-timeline.timer" "snapper-cleanup.timer")
|
|
for timer in "${timers[@]}"; do
|
|
if systemctl list-unit-files "$timer" 2>/dev/null | grep -q "$timer"; then
|
|
sudo systemctl enable --now "$timer"
|
|
success "$timer enabled and started"
|
|
else
|
|
warn "$timer not found — snapper timers may need manual setup on this distro"
|
|
fi
|
|
done
|
|
}
|
|
|
|
# ─── Snapshot Test ────────────────────────────────────────────────────────────
|
|
test_snapshot() {
|
|
local mount="$1"
|
|
local name
|
|
name=$(config_name_from_mount "$mount")
|
|
|
|
step "Snapshot Test — $mount (config: $name)"
|
|
|
|
info "Creating test snapshot ..."
|
|
local snap_num
|
|
snap_num=$(sudo snapper -c "$name" create --description "snapper-setup-test" --print-number)
|
|
success "Snapshot #$snap_num created"
|
|
|
|
info "Current snapshots for '$name':"
|
|
sudo snapper -c "$name" list
|
|
|
|
info "Deleting test snapshot #$snap_num ..."
|
|
sudo snapper -c "$name" delete "$snap_num"
|
|
success "Test snapshot deleted — snapshot test passed"
|
|
}
|
|
|
|
# ─── Main ─────────────────────────────────────────────────────────────────────
|
|
main() {
|
|
echo -e "${BOLD}${CYAN}"
|
|
echo "╔══════════════════════════════════════════╗"
|
|
echo "║ Snapper Auto Configuration ║"
|
|
echo "╚══════════════════════════════════════════╝"
|
|
echo -e "${NC}"
|
|
|
|
detect_os
|
|
ensure_sudo
|
|
install_snapper
|
|
select_subvolumes
|
|
|
|
for mount in "${SELECTED_MOUNTS[@]}"; do
|
|
apply_snapper_config "$mount"
|
|
done
|
|
|
|
enable_timers
|
|
|
|
for mount in "${SELECTED_MOUNTS[@]}"; do
|
|
test_snapshot "$mount"
|
|
done
|
|
|
|
# Stop sudo keepalive
|
|
if [ -n "${SUDO_KEEPALIVE_PID:-}" ]; then
|
|
kill "$SUDO_KEEPALIVE_PID" 2>/dev/null || true
|
|
fi
|
|
|
|
echo ""
|
|
echo -e "${BOLD}${GREEN}══ Snapper configuration complete! ══${NC}"
|
|
info "Timeline snapshots will run automatically via systemd timers"
|
|
info "List all configs: sudo snapper list-configs"
|
|
info "View snapshots: sudo snapper -c <config> list"
|
|
info "Manual snapshot: sudo snapper -c <config> create --description 'my-snapshot'"
|
|
}
|
|
|
|
main "$@"
|