diff --git a/linux-managements/setup-snapper.sh b/linux-managements/setup-snapper.sh new file mode 100644 index 0000000..61152ef --- /dev/null +++ b/linux-managements/setup-snapper.sh @@ -0,0 +1,308 @@ +#!/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 + findmnt -t btrfs -n -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 -o OPTIONS "${mounts[$i]}" 2>/dev/null \ + | tr ',' '\n' | grep '^subvol=' | head -1 | sed 's/subvol=//' || echo "(default)") + device=$(findmnt -n -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 "View snapshots: sudo snapper list" + info "Manual snapshot: sudo snapper -c create --description 'my-snapshot'" +} + +main "$@"