aboutsummaryrefslogtreecommitdiff
path: root/custom/zfs-snap-prune
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-01-18 11:15:57 -0600
committerCraig Jennings <c@cjennings.net>2026-01-18 11:15:57 -0600
commit2e8e5cdd980098241fbd5f6d92f05111818f574a (patch)
tree0f1df49d25586477c58439efe9fad74a5d5dfcf4 /custom/zfs-snap-prune
parent6505511f2e6b43a37570fc840f6d2851c7cc170c (diff)
downloadarchangel-2e8e5cdd980098241fbd5f6d92f05111818f574a.tar.gz
archangel-2e8e5cdd980098241fbd5f6d92f05111818f574a.zip
Add snapshot retention with automatic pruning
Implements hybrid retention policy: - Always keep 20 most recent snapshots - Delete snapshots beyond #20 only if older than 180 days - Genesis snapshot is always protected Features: - zfs-snap-prune script with --dry-run, --test, --verbose modes - Comprehensive test suite (22 tests) - Runs automatically after pacman operations - Daily systemd timer for cleanup - Regenerates GRUB menu after pruning This prevents unbounded snapshot growth while preserving recent history and the genesis snapshot.
Diffstat (limited to 'custom/zfs-snap-prune')
-rwxr-xr-xcustom/zfs-snap-prune208
1 files changed, 208 insertions, 0 deletions
diff --git a/custom/zfs-snap-prune b/custom/zfs-snap-prune
new file mode 100755
index 0000000..762ff99
--- /dev/null
+++ b/custom/zfs-snap-prune
@@ -0,0 +1,208 @@
+#!/bin/bash
+# zfs-snap-prune - Prune old ZFS snapshots with hybrid retention policy
+#
+# Retention Policy:
+# - Always keep the N most recent snapshots (default: 20)
+# - Delete snapshots beyond N only if older than MAX_AGE (default: 180 days)
+# - Never delete genesis snapshot
+#
+# Usage:
+# zfs-snap-prune [OPTIONS]
+#
+# Options:
+# --dry-run Show what would be deleted without deleting
+# --verbose Show decision for every snapshot
+# --quiet Suppress non-error output
+# --test Use mock data from stdin instead of real ZFS
+# --help Show this help message
+#
+# Environment variables:
+# POOL_NAME - ZFS pool name (default: zroot)
+# ROOT_DATASET - Root dataset path (default: ROOT/default)
+# KEEP_COUNT - Number of recent snapshots to always keep (default: 20)
+# MAX_AGE_DAYS - Delete older snapshots beyond KEEP_COUNT (default: 180)
+# NOW_OVERRIDE - Override current timestamp for testing (epoch seconds)
+
+set -e
+
+# Configuration (can be overridden by environment)
+POOL_NAME="${POOL_NAME:-zroot}"
+ROOT_DATASET="${ROOT_DATASET:-ROOT/default}"
+KEEP_COUNT="${KEEP_COUNT:-20}"
+MAX_AGE_DAYS="${MAX_AGE_DAYS:-180}"
+
+FULL_DATASET="${POOL_NAME}/${ROOT_DATASET}"
+
+# Flags
+DRY_RUN=false
+VERBOSE=false
+QUIET=false
+TEST_MODE=false
+
+# Colors for output
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+BLUE='\033[0;34m'
+NC='\033[0m'
+
+usage() {
+ sed -n '2,/^$/p' "$0" | sed 's/^# \?//'
+ exit 0
+}
+
+info() {
+ [[ "$QUIET" == "true" ]] && return
+ echo -e "${GREEN}[INFO]${NC} $1"
+}
+
+verbose() {
+ [[ "$VERBOSE" != "true" ]] && return
+ echo -e "${BLUE}[VERBOSE]${NC} $1"
+}
+
+warn() {
+ [[ "$QUIET" == "true" ]] && return
+ echo -e "${YELLOW}[WARN]${NC} $1"
+}
+
+error() {
+ echo -e "${RED}[ERROR]${NC} $1" >&2
+ exit 1
+}
+
+# Parse arguments
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --dry-run)
+ DRY_RUN=true
+ shift
+ ;;
+ --verbose)
+ VERBOSE=true
+ shift
+ ;;
+ --quiet)
+ QUIET=true
+ shift
+ ;;
+ --test)
+ TEST_MODE=true
+ shift
+ ;;
+ --help|-h)
+ usage
+ ;;
+ *)
+ error "Unknown option: $1"
+ ;;
+ esac
+done
+
+# Check if running as root (skip in test mode)
+if [[ "$TEST_MODE" != "true" ]] && [[ $EUID -ne 0 ]]; then
+ error "This script must be run as root"
+fi
+
+# Get current timestamp (can be overridden for testing)
+NOW="${NOW_OVERRIDE:-$(date +%s)}"
+MAX_AGE_SECONDS=$((MAX_AGE_DAYS * 24 * 60 * 60))
+CUTOFF_TIME=$((NOW - MAX_AGE_SECONDS))
+
+info "Pruning snapshots for ${FULL_DATASET}"
+info "Policy: Keep ${KEEP_COUNT} recent, delete if older than ${MAX_AGE_DAYS} days"
+[[ "$DRY_RUN" == "true" ]] && info "DRY RUN - no changes will be made"
+
+# Get snapshots - either from ZFS or stdin (test mode)
+# Expected format: snapshot_name<TAB>creation_date_string
+# Example: zroot/ROOT/default@pre-pacman_2025-01-15 Wed Jan 15 10:30 2025
+if [[ "$TEST_MODE" == "true" ]]; then
+ # Read mock data from stdin
+ SNAPSHOTS=$(cat | tac)
+else
+ # Query real ZFS - sorted by creation (oldest first), then reversed for newest first
+ SNAPSHOTS=$(zfs list -H -t snapshot -o name,creation -s creation -r "$FULL_DATASET" 2>/dev/null | \
+ grep "^${FULL_DATASET}@" | \
+ tac) || true
+fi
+
+if [[ -z "$SNAPSHOTS" ]]; then
+ info "No snapshots found"
+ exit 0
+fi
+
+# Count snapshots
+TOTAL=$(echo "$SNAPSHOTS" | wc -l)
+info "Found ${TOTAL} snapshots"
+
+# Track results
+DELETED=0
+KEPT=0
+POSITION=0
+
+# Process each snapshot
+while IFS=$'\t' read -r snapshot creation_str; do
+ [[ -z "$snapshot" ]] && continue
+
+ POSITION=$((POSITION + 1))
+ SNAP_NAME="${snapshot##*@}"
+
+ # Parse creation time
+ if [[ "$TEST_MODE" == "true" ]]; then
+ # In test mode, creation_str is epoch seconds
+ SNAP_TIME="$creation_str"
+ else
+ # In real mode, parse date string
+ SNAP_TIME=$(date -d "$creation_str" +%s 2>/dev/null || echo "0")
+ fi
+
+ AGE_DAYS=$(( (NOW - SNAP_TIME) / 86400 ))
+
+ # Decision logic
+ if [[ $POSITION -le $KEEP_COUNT ]]; then
+ # Always keep the first KEEP_COUNT snapshots (most recent)
+ verbose "KEEP: ${SNAP_NAME} (position ${POSITION}/${KEEP_COUNT}, ${AGE_DAYS} days old) - within keep count"
+ KEPT=$((KEPT + 1))
+ elif [[ "$SNAP_NAME" == "genesis" ]]; then
+ # Never delete genesis
+ verbose "KEEP: ${SNAP_NAME} (position ${POSITION}, ${AGE_DAYS} days old) - genesis protected"
+ KEPT=$((KEPT + 1))
+ elif [[ $SNAP_TIME -ge $CUTOFF_TIME ]]; then
+ # Not old enough to delete
+ verbose "KEEP: ${SNAP_NAME} (position ${POSITION}, ${AGE_DAYS} days old) - younger than ${MAX_AGE_DAYS} days"
+ KEPT=$((KEPT + 1))
+ else
+ # Delete: beyond keep count AND older than max age
+ if [[ "$DRY_RUN" == "true" ]]; then
+ info "WOULD DELETE: ${SNAP_NAME} (position ${POSITION}, ${AGE_DAYS} days old)"
+ DELETED=$((DELETED + 1))
+ elif [[ "$TEST_MODE" == "true" ]]; then
+ # Test mode: simulate deletion (don't actually call zfs)
+ verbose "DELETE: ${SNAP_NAME} (position ${POSITION}, ${AGE_DAYS} days old)"
+ DELETED=$((DELETED + 1))
+ else
+ verbose "DELETE: ${SNAP_NAME} (position ${POSITION}, ${AGE_DAYS} days old)"
+ if zfs destroy "$snapshot" 2>/dev/null; then
+ DELETED=$((DELETED + 1))
+ else
+ warn "Failed to delete ${snapshot}"
+ fi
+ fi
+ fi
+done <<< "$SNAPSHOTS"
+
+# Summary
+info "Summary: ${KEPT} kept, ${DELETED} deleted"
+
+# Regenerate GRUB menu if we deleted anything (skip in dry-run and test modes)
+if [[ $DELETED -gt 0 ]] && [[ "$DRY_RUN" != "true" ]] && [[ "$TEST_MODE" != "true" ]]; then
+ if [[ -x /usr/local/bin/grub-zfs-snap ]]; then
+ info "Regenerating GRUB menu..."
+ /usr/local/bin/grub-zfs-snap
+ fi
+fi
+
+# Exit with special code for testing (number of deleted)
+if [[ "$TEST_MODE" == "true" ]]; then
+ echo "RESULT:kept=${KEPT},deleted=${DELETED}"
+fi