aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-04-27 18:33:03 -0500
committerCraig Jennings <c@cjennings.net>2026-04-27 18:33:03 -0500
commit422d1098cd89beaeed81cc40488252233e2ca0ad (patch)
tree7ea92619f7a76bc797851776cf1901d91b1e458f
parentea494c7d0fc41bb1cab888f92408fab29c190e75 (diff)
downloadarchangel-422d1098cd89beaeed81cc40488252233e2ca0ad.tar.gz
archangel-422d1098cd89beaeed81cc40488252233e2ca0ad.zip
feat: consolidate zfssnapshot and zfsrollback into one subcommand-driven script
Problem: zfssnapshot and zfsrollback were two separate scripts with overlapping pre-flight checks (zfs / fzf / root) and parallel UX patterns (description sanitization in one, fzf selection in the other). Users had to remember which script was for which operation, and a "list" view meant typing the raw `zfs list -t snapshot` command. There was no path to destroy individual snapshots short of `zfs destroy` directly, which is dangerous without a confirmation flow. Solution: rewrite zfssnapshot as a single multi-subcommand script (list, create, rollback, delete). Drop installer/zfsrollback. The new script uses a source-guard at the bottom (`if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then main "$@"; fi`) so bats can source it without triggering the install-time pre-flight checks, matching the pattern in installer/archangel. Pure helpers (sanitize_description, validate_description, format_snapshot_name) get extracted as named functions so they're testable in isolation. The destructive flows (rollback, delete) keep the explicit "yes" confirmation prompt, the genesis-snapshot warning, and the recursive-rollback-destroys-newer-snapshots warning. Delete uses fzf --multi so the user can pick several snapshot names at once. Updated build.sh to copy only the consolidated script. Dropped the zfsrollback profiledef permission line. Updated Makefile, README, scripts/sanity-test.sh, and testing-strategy.org to reflect the single-script layout. Bats: 147 → 168 (+21). Coverage spans sanitize_description (normal / boundary / error), validate_description (alphanumerics, hyphens, underscores accepted; spaces, slashes, shell metacharacters, empty rejected), format_snapshot_name (timestamp + description composition), and main subcommand dispatch (list / create / rollback / delete / help / unknown). Lint clean. The zfs-, fzf-, and arch-chroot-shelling subcommand bodies stay VM-tested per testing-strategy.org.
-rw-r--r--Makefile2
-rw-r--r--README.org18
-rwxr-xr-xbuild.sh8
-rwxr-xr-xinstaller/zfsrollback179
-rwxr-xr-xinstaller/zfssnapshot480
-rwxr-xr-xscripts/sanity-test.sh6
-rw-r--r--testing-strategy.org1
-rw-r--r--tests/unit/test_zfssnapshot.bats153
8 files changed, 573 insertions, 274 deletions
diff --git a/Makefile b/Makefile
index 56c0efd..8f39f7c 100644
--- a/Makefile
+++ b/Makefile
@@ -31,7 +31,7 @@ SHELL := /bin/bash
# Lint all bash scripts
lint:
@echo "==> Running shellcheck..."
- @shellcheck -x build.sh scripts/*.sh installer/archangel installer/zfsrollback installer/zfssnapshot installer/lib/*.sh
+ @shellcheck -x build.sh scripts/*.sh installer/archangel installer/zfssnapshot installer/lib/*.sh
@echo "==> Shellcheck complete"
# Run bats unit tests
diff --git a/README.org b/README.org
index 905e4e8..edb1721 100644
--- a/README.org
+++ b/README.org
@@ -32,7 +32,7 @@ Archangel currently uses linux-lts for stability. Choosing linux and linux-zen k
|------------------+----------------------------+----------------------|
| Bootloader | ZFSBootMenu | GRUB + grub-btrfs |
| Encryption | Native ZFS encryption | LUKS2 |
-| Snapshot utility | zfssnapshot helper scripts | snapper |
+| Snapshot utility | zfssnapshot | snapper |
| Snapshot boot | Built into ZFSBootMenu | grub-btrfs menu |
| RAID support | mirror, raidz1/2/3, stripe | RAID0, RAID1, RAID10 |
| EFI size | 512MB | 1GB |
@@ -258,14 +258,17 @@ A complete example with all options is available at ~installer/archangel.conf.ex
** ZFS Snapshot Management
#+BEGIN_SRC bash
+# List snapshots
+zfssnapshot list
+
# Create a snapshot
-zfssnapshot "before-experiment"
+zfssnapshot create "before-experiment"
-# Interactive rollback with fzf
-zfsrollback
+# Interactive rollback (fzf)
+zfssnapshot rollback
-# List snapshots
-zfs list -t snapshot
+# Interactive multi-select destroy (fzf)
+zfssnapshot delete
#+END_SRC
** Btrfs Snapshot Management
@@ -347,8 +350,7 @@ archangel/
│ │ ├── disk.sh # Disk partitioning and EFI formatting
│ │ ├── btrfs.sh # Btrfs-specific functions (LUKS, subvolumes, GRUB)
│ │ └── raid.sh # Pure RAID-level logic (levels, validation, usable space)
-│ ├── zfssnapshot # ZFS snapshot utility
-│ ├── zfsrollback # ZFS rollback utility
+│ ├── zfssnapshot # ZFS snapshot utility (list/create/rollback/delete)
│ └── RESCUE-GUIDE.txt # Recovery tools documentation
├── tests/
│ └── unit/ # Bats unit tests for installer/lib/*.sh
diff --git a/build.sh b/build.sh
index 06399ec..cc6686a 100755
--- a/build.sh
+++ b/build.sh
@@ -386,10 +386,9 @@ info "Copying custom scripts..."
cp "$INSTALLER_DIR/archangel" "$PROFILE_DIR/airootfs/usr/local/bin/"
cp -r "$INSTALLER_DIR/lib" "$PROFILE_DIR/airootfs/usr/local/bin/"
cp "$INSTALLER_DIR/install-claude" "$PROFILE_DIR/airootfs/usr/local/bin/"
-# Copy zfssnapshot and zfsrollback for ZFS management
-info "Copying zfssnapshot and zfsrollback..."
+# Copy zfssnapshot for ZFS snapshot management (list/create/rollback/delete)
+info "Copying zfssnapshot..."
cp "$INSTALLER_DIR/zfssnapshot" "$PROFILE_DIR/airootfs/usr/local/bin/"
-cp "$INSTALLER_DIR/zfsrollback" "$PROFILE_DIR/airootfs/usr/local/bin/"
# Copy example config for unattended installs
mkdir -p "$PROFILE_DIR/airootfs/root"
@@ -412,9 +411,6 @@ if grep -q "file_permissions=" "$PROFILE_DIR/profiledef.sh"; then
/)/ i\ ["/usr/local/bin/zfssnapshot"]="0:0:755"
}' "$PROFILE_DIR/profiledef.sh"
sed -i '/^file_permissions=(/,/)/ {
- /)/ i\ ["/usr/local/bin/zfsrollback"]="0:0:755"
- }' "$PROFILE_DIR/profiledef.sh"
- sed -i '/^file_permissions=(/,/)/ {
/)/ i\ ["/usr/local/bin/lib/common.sh"]="0:0:755"
}' "$PROFILE_DIR/profiledef.sh"
sed -i '/^file_permissions=(/,/)/ {
diff --git a/installer/zfsrollback b/installer/zfsrollback
deleted file mode 100755
index a99a4d3..0000000
--- a/installer/zfsrollback
+++ /dev/null
@@ -1,179 +0,0 @@
-#!/usr/bin/env bash
-# Craig Jennings (github.com/cjennings)
-# Roll back ZFS datasets to a selected snapshot using fzf.
-
-set -euo pipefail
-
-# Usage info
-show_help() {
- cat << EOF
-Usage: ${0##*/} [-h] [-s]
-Roll back ZFS datasets to a selected snapshot.
-
- -h display this help and exit
- -s single dataset mode (roll back only the selected dataset,
- not all datasets with matching snapshot name)
-
-By default, rolling back a snapshot will roll back ALL datasets that share
-that snapshot name. Use -s for single dataset rollback.
-
-WARNING: Rolling back destroys all data and snapshots newer than the target.
- This operation cannot be undone!
-
-Requires: fzf, zfs
-EOF
-}
-
-# Check dependencies
-for cmd in zfs fzf; do
- if ! command -v "$cmd" &> /dev/null; then
- echo "Error: $cmd command not found"
- exit 1
- fi
-done
-
-# Check for root/sudo
-if [ "$EUID" -ne 0 ]; then
- echo "Error: This script must be run as root (use sudo)"
- exit 1
-fi
-
-# Parse arguments
-single_mode=false
-while getopts ":hs" opt; do
- case ${opt} in
- h)
- show_help
- exit 0
- ;;
- s)
- single_mode=true
- ;;
- \?)
- echo "Invalid option: -$OPTARG" >&2
- show_help
- exit 1
- ;;
- esac
-done
-
-# Get all snapshots
-snapshots=$(zfs list -t snapshot -H -o name 2>/dev/null)
-
-if [ -z "$snapshots" ]; then
- echo "No ZFS snapshots found"
- exit 0
-fi
-
-if $single_mode; then
- # Single mode: show full dataset@snapshot names (sorted newest first)
- selected=$(zfs list -t snapshot -H -o name -S creation | fzf --height=70% --reverse \
- --header="Select snapshot to roll back (ESC to cancel)" \
- --preview="zfs list -t snapshot -o name,creation,used,refer -r {1}" \
- --preview-window=down:5)
-
- if [ -z "$selected" ]; then
- echo "No snapshot selected, exiting"
- exit 0
- fi
-
- dataset="${selected%@*}"
- snap_name="${selected#*@}"
- targets=("$selected")
-else
- # Multi mode: show unique snapshot names, roll back all matching datasets
- # Sort reverse so newest (latest date) appears at top
- unique_snaps=$(echo "$snapshots" | sed 's/.*@//' | sort -ru)
-
- snap_name=$(echo "$unique_snaps" | fzf --height=70% --reverse \
- --header="Select snapshot name to roll back ALL matching datasets (ESC to cancel)" \
- --preview="zfs list -t snapshot -o name,creation,used -H | grep '@{}$' | column -t" \
- --preview-window=down:10)
-
- if [ -z "$snap_name" ]; then
- echo "No snapshot selected, exiting"
- exit 0
- fi
-
- # Find all datasets with this snapshot, sorted by depth (deepest first)
- # This ensures children are rolled back before parents
- mapfile -t targets < <(echo "$snapshots" | grep "@${snap_name}$" | awk -F'@' '{print length($1), $0}' | sort -rn | cut -d' ' -f2-)
-
- if [ ${#targets[@]} -eq 0 ]; then
- echo "Error: No datasets found with snapshot @${snap_name}"
- exit 1
- fi
-fi
-
-# Display what will happen
-echo ""
-echo "═══════════════════════════════════════════════════════════════════"
-echo " ⚠️ WARNING ⚠️"
-echo "═══════════════════════════════════════════════════════════════════"
-echo ""
-
-# Special warning for genesis rollback
-if [[ "$snap_name" == "genesis" ]]; then
- echo " 🚨 GENESIS ROLLBACK DETECTED 🚨"
- echo ""
- echo " Rolling back to genesis will destroy ALL changes since installation!"
- echo " This includes all packages installed, configurations, and user data."
- echo ""
-fi
-
-echo "You are about to roll back to snapshot: @${snap_name}"
-echo ""
-echo "The following datasets will be rolled back:"
-for target in "${targets[@]}"; do
- dataset="${target%@*}"
- echo " • $dataset"
-
- # Show newer snapshots that will be destroyed
- newer=$(zfs list -t snapshot -H -o name -S creation "$dataset" 2>/dev/null | \
- awk -v snap="$target" 'found {print " ✗ " $0 " (will be DESTROYED)"} $0 == snap {found=1}')
- if [ -n "$newer" ]; then
- echo "$newer"
- fi
-done
-
-echo ""
-echo "═══════════════════════════════════════════════════════════════════"
-echo " THIS OPERATION CANNOT BE UNDONE!"
-echo " All data written after the snapshot will be permanently lost."
-echo " All snapshots newer than the target will be destroyed."
-echo "═══════════════════════════════════════════════════════════════════"
-echo ""
-
-# Require explicit confirmation
-read -r -p "Type 'yes' to confirm rollback: " confirmation
-
-if [ "$confirmation" != "yes" ]; then
- echo "Rollback cancelled"
- exit 0
-fi
-
-echo ""
-echo "Rolling back..."
-
-# Perform rollbacks
-failed=0
-for target in "${targets[@]}"; do
- dataset="${target%@*}"
- echo -n " Rolling back $dataset... "
- if zfs rollback -r "$target" 2>&1; then
- echo "✓"
- else
- echo "✗ FAILED"
- ((failed++))
- fi
-done
-
-echo ""
-if [ $failed -eq 0 ]; then
- echo "Rollback complete."
- echo ""
- echo "Note: ZFSBootMenu auto-detects snapshots - no menu regeneration needed."
-else
- echo "Rollback completed with $failed failure(s)"
- exit 1
-fi
diff --git a/installer/zfssnapshot b/installer/zfssnapshot
index 90331c3..88315a9 100755
--- a/installer/zfssnapshot
+++ b/installer/zfssnapshot
@@ -1,105 +1,435 @@
#!/usr/bin/env bash
# Craig Jennings (github.com/cjennings)
-# Create a ZFS snapshot across all datasets with a dated, descriptive name.
+# zfssnapshot - ZFS snapshot management
+# Subcommands: list, create, rollback, delete
set -euo pipefail
-# Usage info
+#############################
+# Help
+#############################
+
show_help() {
cat << EOF
-Usage: ${0##*/} [-h] [DESCRIPTION]
-Create a ZFS snapshot across all datasets.
+Usage: ${0##*/} <subcommand> [options]
+
+ZFS snapshot management. Recursive across all pools.
- -h display this help and exit
- DESCRIPTION short description for the snapshot (optional, will prompt if omitted)
+Subcommands:
+ list List all snapshots.
+ create [DESCRIPTION] Create a new snapshot. Prompts if DESCRIPTION
+ is omitted. Snapshot name format:
+ YYYY-MM-DD_HH-MM-SS_description.
+ Description must be alphanumeric, hyphens,
+ or underscores. Spaces become underscores.
+ rollback [-s] fzf-select a snapshot, then roll back. By
+ default, rolls back ALL datasets that share
+ the selected snapshot name. -s rolls back
+ only the selected dataset.
+ delete fzf multi-select snapshots to destroy. By
+ default, destroys the snapshot across ALL
+ matching datasets.
-Snapshot names are formatted as: YYYY-MM-DD_HH-MM-SS_description
-Only alphanumeric characters, hyphens, and underscores are allowed in descriptions.
-Spaces are converted to underscores automatically.
+Common options:
+ -h, --help Show this help and exit.
+
+WARNING: rollback and delete destroy data. Both prompt for explicit
+"yes" confirmation. Genesis snapshots get a louder warning.
Examples:
- ${0##*/} before-upgrade
- ${0##*/} "pre system update"
- ${0##*/} # prompts for description
+ ${0##*/} list
+ ${0##*/} create before-upgrade
+ ${0##*/} create "pre system update"
+ ${0##*/} rollback
+ ${0##*/} rollback -s
+ ${0##*/} delete
EOF
}
-# Check for ZFS
-if ! command -v zfs &> /dev/null; then
- echo "Error: zfs command not found. Is ZFS installed?"
- exit 1
-fi
+#############################
+# Pure helpers (bats-tested)
+#############################
-# Check for root/sudo
-if [ "$EUID" -ne 0 ]; then
- echo "Error: This script must be run as root (use sudo)"
- exit 1
-fi
+# Lowercase the input and convert spaces to underscores.
+sanitize_description() {
+ echo "$1" | tr '[:upper:]' '[:lower:]' | tr ' ' '_'
+}
-# Parse arguments
-while getopts ":h" opt; do
- case ${opt} in
- h)
- show_help
+# Return 0 if $1 is non-empty and contains only alphanumerics, hyphens,
+# and underscores. Return 1 otherwise.
+validate_description() {
+ [[ -n "$1" && "$1" =~ ^[a-z0-9_-]+$ ]]
+}
+
+# Compose a snapshot name from a timestamp string and a description.
+# The caller passes the timestamp in (rather than the helper calling
+# date) so tests don't need to mock the clock.
+format_snapshot_name() {
+ local timestamp="$1"
+ local description="$2"
+ echo "${timestamp}_${description}"
+}
+
+#############################
+# Pre-flight checks
+#############################
+
+require_root() {
+ if [ "$EUID" -ne 0 ]; then
+ echo "Error: This script must be run as root (use sudo)" >&2
+ exit 1
+ fi
+}
+
+require_zfs() {
+ if ! command -v zfs &> /dev/null; then
+ echo "Error: zfs command not found. Is ZFS installed?" >&2
+ exit 1
+ fi
+}
+
+require_fzf() {
+ if ! command -v fzf &> /dev/null; then
+ echo "Error: fzf command not found" >&2
+ exit 1
+ fi
+}
+
+#############################
+# Subcommand: list
+#############################
+
+cmd_list() {
+ require_zfs
+ if ! zfs list -t snapshot -o name,creation,used,refer 2>/dev/null; then
+ echo "No ZFS snapshots found"
+ fi
+}
+
+#############################
+# Subcommand: create
+#############################
+
+cmd_create() {
+ require_root
+ require_zfs
+
+ local description
+ if [ $# -ge 1 ]; then
+ description="$*"
+ else
+ read -r -p "Enter snapshot description: " description
+ fi
+
+ description=$(sanitize_description "$description")
+
+ if ! validate_description "$description"; then
+ echo "Error: Description must be non-empty and contain only" >&2
+ echo "letters, numbers, hyphens, and underscores." >&2
+ echo "Sanitized input was: $description" >&2
+ exit 1
+ fi
+
+ local timestamp snapshot_name pools
+ timestamp=$(date +%Y-%m-%d_%H-%M-%S)
+ snapshot_name=$(format_snapshot_name "$timestamp" "$description")
+ pools=$(zpool list -H -o name)
+
+ if [ -z "$pools" ]; then
+ echo "Error: No ZFS pools found" >&2
+ exit 1
+ fi
+
+ echo "Creating snapshots with name: @${snapshot_name}"
+ echo ""
+
+ for pool in $pools; do
+ echo "Snapshotting pool: $pool"
+ if zfs snapshot -r "${pool}@${snapshot_name}"; then
+ echo " ✓ Created ${pool}@${snapshot_name} (recursive)"
+ else
+ echo " ✗ Failed to snapshot $pool"
+ fi
+ done
+
+ echo ""
+ echo "Snapshot complete. Verify with: zfs list -t snapshot | grep $snapshot_name"
+ echo ""
+ echo "To boot from this snapshot: reboot and press Ctrl+D at ZFSBootMenu"
+}
+
+#############################
+# Subcommand: rollback
+#############################
+
+cmd_rollback() {
+ require_root
+ require_zfs
+ require_fzf
+
+ local single_mode=false
+ while getopts ":hs" opt; do
+ case ${opt} in
+ h) show_help; exit 0 ;;
+ s) single_mode=true ;;
+ \?) echo "Invalid option: -$OPTARG" >&2; exit 1 ;;
+ esac
+ done
+
+ local snapshots
+ snapshots=$(zfs list -t snapshot -H -o name 2>/dev/null)
+
+ if [ -z "$snapshots" ]; then
+ echo "No ZFS snapshots found"
+ exit 0
+ fi
+
+ local snap_name selected
+ local -a targets
+
+ if $single_mode; then
+ # Single mode: pick a specific dataset@snapshot.
+ selected=$(zfs list -t snapshot -H -o name -S creation | fzf --height=70% --reverse \
+ --header="Select snapshot to roll back (ESC to cancel)" \
+ --preview="zfs list -t snapshot -o name,creation,used,refer -r {1}" \
+ --preview-window=down:5)
+
+ if [ -z "$selected" ]; then
+ echo "No snapshot selected, exiting"
exit 0
- ;;
- \?)
- echo "Invalid option: -$OPTARG" >&2
- show_help
+ fi
+
+ snap_name="${selected#*@}"
+ targets=("$selected")
+ else
+ # Multi mode: pick a snapshot NAME, roll back every dataset
+ # carrying that name. Children rolled back before parents
+ # (sort by dataset path length, descending).
+ local unique_snaps
+ unique_snaps=$(echo "$snapshots" | sed 's/.*@//' | sort -ru)
+
+ snap_name=$(echo "$unique_snaps" | fzf --height=70% --reverse \
+ --header="Select snapshot name to roll back ALL matching datasets (ESC to cancel)" \
+ --preview="zfs list -t snapshot -o name,creation,used -H | grep '@{}\$' | column -t" \
+ --preview-window=down:10)
+
+ if [ -z "$snap_name" ]; then
+ echo "No snapshot selected, exiting"
+ exit 0
+ fi
+
+ mapfile -t targets < <(echo "$snapshots" | grep "@${snap_name}$" \
+ | awk -F'@' '{print length($1), $0}' | sort -rn | cut -d' ' -f2-)
+
+ if [ ${#targets[@]} -eq 0 ]; then
+ echo "Error: No datasets found with snapshot @${snap_name}" >&2
exit 1
- ;;
- esac
-done
-shift $((OPTIND - 1))
-
-# Get description from argument or prompt
-if [ $# -ge 1 ]; then
- description="$*"
-else
- read -r -p "Enter snapshot description: " description
- if [ -z "$description" ]; then
- echo "Error: Description cannot be empty"
+ fi
+ fi
+
+ echo ""
+ echo "═══════════════════════════════════════════════════════════════════"
+ echo " ⚠️ WARNING ⚠️"
+ echo "═══════════════════════════════════════════════════════════════════"
+ echo ""
+
+ if [[ "$snap_name" == "genesis" ]]; then
+ echo " 🚨 GENESIS ROLLBACK DETECTED 🚨"
+ echo ""
+ echo " Rolling back to genesis will destroy ALL changes since installation!"
+ echo " This includes all packages installed, configurations, and user data."
+ echo ""
+ fi
+
+ echo "You are about to roll back to snapshot: @${snap_name}"
+ echo ""
+ echo "The following datasets will be rolled back:"
+ for target in "${targets[@]}"; do
+ local dataset="${target%@*}"
+ echo " • $dataset"
+
+ local newer
+ newer=$(zfs list -t snapshot -H -o name -S creation "$dataset" 2>/dev/null | \
+ awk -v snap="$target" 'found {print " ✗ " $0 " (will be DESTROYED)"} $0 == snap {found=1}')
+ if [ -n "$newer" ]; then
+ echo "$newer"
+ fi
+ done
+
+ echo ""
+ echo "═══════════════════════════════════════════════════════════════════"
+ echo " THIS OPERATION CANNOT BE UNDONE!"
+ echo " All data written after the snapshot will be permanently lost."
+ echo " All snapshots newer than the target will be destroyed."
+ echo "═══════════════════════════════════════════════════════════════════"
+ echo ""
+
+ local confirmation
+ read -r -p "Type 'yes' to confirm rollback: " confirmation
+
+ if [ "$confirmation" != "yes" ]; then
+ echo "Rollback cancelled"
+ exit 0
+ fi
+
+ echo ""
+ echo "Rolling back..."
+
+ local failed=0
+ for target in "${targets[@]}"; do
+ local dataset="${target%@*}"
+ echo -n " Rolling back $dataset... "
+ if zfs rollback -r "$target" 2>&1; then
+ echo "✓"
+ else
+ echo "✗ FAILED"
+ ((failed++))
+ fi
+ done
+
+ echo ""
+ if [ $failed -eq 0 ]; then
+ echo "Rollback complete."
+ echo ""
+ echo "Note: ZFSBootMenu auto-detects snapshots - no menu regeneration needed."
+ else
+ echo "Rollback completed with $failed failure(s)" >&2
exit 1
fi
-fi
+}
-# Sanitize description: convert spaces to underscores, lowercase
-description=$(echo "$description" | tr '[:upper:]' '[:lower:]' | tr ' ' '_')
+#############################
+# Subcommand: delete
+#############################
-# Validate description: only allow alphanumeric, hyphens, underscores
-if [[ ! "$description" =~ ^[a-z0-9_-]+$ ]]; then
- echo "Error: Description contains invalid characters"
- echo "Only letters, numbers, hyphens, and underscores are allowed"
- echo "Sanitized input was: $description"
- exit 1
-fi
+cmd_delete() {
+ require_root
+ require_zfs
+ require_fzf
-# Create snapshot name with timestamp prefix (matches pre-pacman format)
-timestamp=$(date +%Y-%m-%d_%H-%M-%S)
-snapshot_name="${timestamp}_${description}"
+ local snapshots
+ snapshots=$(zfs list -t snapshot -H -o name 2>/dev/null)
-# Get all pools
-pools=$(zpool list -H -o name)
+ if [ -z "$snapshots" ]; then
+ echo "No ZFS snapshots found"
+ exit 0
+ fi
-if [ -z "$pools" ]; then
- echo "Error: No ZFS pools found"
- exit 1
-fi
+ # Pick snapshot NAMES (not full dataset@name); destroy each across
+ # every dataset that carries it. Same shape as rollback's default
+ # so the user model is consistent.
+ local unique_snaps
+ unique_snaps=$(echo "$snapshots" | sed 's/.*@//' | sort -ru)
-echo "Creating snapshots with name: @${snapshot_name}"
-echo ""
+ local selected_names
+ selected_names=$(echo "$unique_snaps" | fzf --height=70% --reverse --multi \
+ --header="TAB to multi-select snapshots to DESTROY (ESC to cancel)" \
+ --preview="zfs list -t snapshot -o name,creation,used -H | grep '@{}\$' | column -t" \
+ --preview-window=down:10)
-# Create recursive snapshots on each pool
-for pool in $pools; do
- echo "Snapshotting pool: $pool"
- if zfs snapshot -r "${pool}@${snapshot_name}"; then
- echo " ✓ Created ${pool}@${snapshot_name} (recursive)"
+ if [ -z "$selected_names" ]; then
+ echo "No snapshots selected, exiting"
+ exit 0
+ fi
+
+ # Expand selected names to full dataset@name targets.
+ local -a targets=()
+ while IFS= read -r snap_name; do
+ [[ -z "$snap_name" ]] && continue
+ while IFS= read -r match; do
+ [[ -n "$match" ]] && targets+=("$match")
+ done < <(echo "$snapshots" | grep "@${snap_name}$" || true)
+ done <<< "$selected_names"
+
+ if [ ${#targets[@]} -eq 0 ]; then
+ echo "Error: No datasets found for the selected snapshot names" >&2
+ exit 1
+ fi
+
+ echo ""
+ echo "═══════════════════════════════════════════════════════════════════"
+ echo " ⚠️ WARNING ⚠️"
+ echo "═══════════════════════════════════════════════════════════════════"
+ echo ""
+
+ local genesis_selected=false
+ while IFS= read -r snap_name; do
+ [[ "$snap_name" == "genesis" ]] && genesis_selected=true
+ done <<< "$selected_names"
+
+ if $genesis_selected; then
+ echo " 🚨 GENESIS DESTROY DETECTED 🚨"
+ echo ""
+ echo " Destroying the genesis snapshot loses the post-install"
+ echo " recovery point. The system can no longer roll back to a"
+ echo " pristine state."
+ echo ""
+ fi
+
+ echo "The following ${#targets[@]} snapshot(s) will be destroyed:"
+ for target in "${targets[@]}"; do
+ echo " • $target"
+ done
+
+ echo ""
+ echo "═══════════════════════════════════════════════════════════════════"
+ echo " THIS OPERATION CANNOT BE UNDONE!"
+ echo "═══════════════════════════════════════════════════════════════════"
+ echo ""
+
+ local confirmation
+ read -r -p "Type 'yes' to confirm destroy: " confirmation
+
+ if [ "$confirmation" != "yes" ]; then
+ echo "Destroy cancelled"
+ exit 0
+ fi
+
+ echo ""
+ echo "Destroying..."
+
+ local failed=0
+ for target in "${targets[@]}"; do
+ echo -n " Destroying $target... "
+ if zfs destroy "$target" 2>&1; then
+ echo "✓"
+ else
+ echo "✗ FAILED"
+ ((failed++))
+ fi
+ done
+
+ echo ""
+ if [ $failed -eq 0 ]; then
+ echo "Destroy complete."
else
- echo " ✗ Failed to snapshot $pool"
+ echo "Destroy completed with $failed failure(s)" >&2
+ exit 1
fi
-done
+}
-echo ""
-echo "Snapshot complete. Verify with: zfs list -t snapshot | grep $snapshot_name"
-echo ""
-echo "To boot from this snapshot: reboot and press Ctrl+D at ZFSBootMenu"
+#############################
+# Dispatch
+#############################
+
+main() {
+ local subcmd="${1:-}"
+ shift || true
+
+ case "$subcmd" in
+ list) cmd_list "$@" ;;
+ create) cmd_create "$@" ;;
+ rollback) cmd_rollback "$@" ;;
+ delete) cmd_delete "$@" ;;
+ ""|-h|--help|help) show_help ;;
+ *)
+ echo "Unknown subcommand: $subcmd" >&2
+ show_help
+ exit 1
+ ;;
+ esac
+}
+
+if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
+ main "$@"
+fi
diff --git a/scripts/sanity-test.sh b/scripts/sanity-test.sh
index 95e537a..30afe2a 100755
--- a/scripts/sanity-test.sh
+++ b/scripts/sanity-test.sh
@@ -236,15 +236,11 @@ run_sanity_tests() {
"test -x /usr/local/bin/archangel && echo 'exists'" \
"exists"
- run_test "zfsrollback script present" \
- "test -x /usr/local/bin/zfsrollback && echo 'exists'" \
- "exists"
-
run_test "zfssnapshot script present" \
"test -x /usr/local/bin/zfssnapshot && echo 'exists'" \
"exists"
- # Test 5: fzf installed (required by zfsrollback)
+ # Test 5: fzf installed (required by zfssnapshot rollback/delete)
run_test "fzf installed" \
"command -v fzf && echo 'found'" \
"found"
diff --git a/testing-strategy.org b/testing-strategy.org
index f618bd1..6f254e7 100644
--- a/testing-strategy.org
+++ b/testing-strategy.org
@@ -67,6 +67,7 @@ Current coverage lives in =tests/unit/=:
| =test_disk.bats= | =get_efi_partition=, =get_root_partition=, =partition_disks= (orchestration shape) |
| =test_btrfs.bats= | =get_luks_devices= |
| =test_archangel.bats= | =gather_input= (unattended branch only), =install_failure_cleanup= (dispatch shape) |
+| =test_zfssnapshot.bats= | =sanitize_description=, =validate_description=, =format_snapshot_name=, =main= subcommand dispatch |
** What bats does NOT cover (deliberately)
diff --git a/tests/unit/test_zfssnapshot.bats b/tests/unit/test_zfssnapshot.bats
new file mode 100644
index 0000000..74e13cc
--- /dev/null
+++ b/tests/unit/test_zfssnapshot.bats
@@ -0,0 +1,153 @@
+#!/usr/bin/env bats
+# Unit tests for installer/zfssnapshot
+#
+# Coverage scope: pure-logic helpers and subcommand dispatch. The
+# subcommand bodies that shell out to zfs / fzf / arch-chroot are VM-
+# tested per testing-strategy.org.
+#
+# Sourcing zfssnapshot relies on the source-guard at the bottom of the
+# script: when sourced, function definitions load but main is not
+# called.
+
+setup() {
+ # shellcheck disable=SC1091
+ source "${BATS_TEST_DIRNAME}/../../installer/zfssnapshot"
+}
+
+#############################
+# sanitize_description
+#############################
+
+@test "sanitize_description lowercases mixed case" {
+ [ "$(sanitize_description 'Before Upgrade')" = "before_upgrade" ]
+}
+
+@test "sanitize_description converts spaces to underscores" {
+ [ "$(sanitize_description 'pre system update')" = "pre_system_update" ]
+}
+
+@test "sanitize_description leaves valid input unchanged" {
+ [ "$(sanitize_description 'before-upgrade')" = "before-upgrade" ]
+}
+
+@test "sanitize_description handles a single word" {
+ [ "$(sanitize_description 'experiment')" = "experiment" ]
+}
+
+#############################
+# validate_description
+#############################
+
+@test "validate_description accepts alphanumeric" {
+ run validate_description "abc123"
+ [ "$status" -eq 0 ]
+}
+
+@test "validate_description accepts hyphens" {
+ run validate_description "before-upgrade"
+ [ "$status" -eq 0 ]
+}
+
+@test "validate_description accepts underscores" {
+ run validate_description "pre_system_update"
+ [ "$status" -eq 0 ]
+}
+
+@test "validate_description rejects slashes" {
+ run validate_description "bad/name"
+ [ "$status" -ne 0 ]
+}
+
+@test "validate_description rejects spaces" {
+ run validate_description "two words"
+ [ "$status" -ne 0 ]
+}
+
+@test "validate_description rejects shell metacharacters" {
+ run validate_description "name; rm -rf"
+ [ "$status" -ne 0 ]
+}
+
+@test "validate_description rejects an empty string" {
+ run validate_description ""
+ [ "$status" -ne 0 ]
+}
+
+#############################
+# format_snapshot_name
+#############################
+# format_snapshot_name uses an injected timestamp (callers pass it in)
+# rather than calling date() inside the helper, so the test doesn't
+# need to mock the clock.
+
+@test "format_snapshot_name composes timestamp + description" {
+ [ "$(format_snapshot_name '2026-04-27_13-22-00' 'before-upgrade')" \
+ = "2026-04-27_13-22-00_before-upgrade" ]
+}
+
+#############################
+# main dispatch
+#############################
+# main routes the first positional arg to a cmd_* function. Tests
+# replace the cmd_* functions with mocks that record invocation, so
+# this layer's behavior (which subcommand for which arg) is what's
+# pinned, not the bodies.
+
+@test "main with no args shows help and exits 0" {
+ run main
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"Usage:"* ]]
+}
+
+@test "main --help shows help and exits 0" {
+ run main --help
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"Usage:"* ]]
+}
+
+@test "main -h shows help and exits 0" {
+ run main -h
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"Usage:"* ]]
+}
+
+@test "main help shows help and exits 0" {
+ run main help
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"Usage:"* ]]
+}
+
+@test "main list dispatches to cmd_list" {
+ cmd_list() { echo "called list with: $*"; }
+ run main list
+ [ "$status" -eq 0 ]
+ [[ "$output" == "called list with: " ]]
+}
+
+@test "main create dispatches to cmd_create with remaining args" {
+ cmd_create() { echo "called create with: $*"; }
+ run main create "before upgrade"
+ [ "$status" -eq 0 ]
+ [[ "$output" == "called create with: before upgrade" ]]
+}
+
+@test "main rollback dispatches to cmd_rollback with remaining args" {
+ cmd_rollback() { echo "called rollback with: $*"; }
+ run main rollback -s
+ [ "$status" -eq 0 ]
+ [[ "$output" == "called rollback with: -s" ]]
+}
+
+@test "main delete dispatches to cmd_delete" {
+ cmd_delete() { echo "called delete with: $*"; }
+ run main delete
+ [ "$status" -eq 0 ]
+ [[ "$output" == "called delete with: " ]]
+}
+
+@test "main rejects an unknown subcommand and exits non-zero" {
+ run main not-a-subcommand
+ [ "$status" -ne 0 ]
+ [[ "$output" == *"not-a-subcommand"* ]]
+ [[ "$output" == *"Usage:"* ]]
+}