diff options
| author | Craig Jennings <c@cjennings.net> | 2026-04-27 18:33:03 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-04-27 18:33:03 -0500 |
| commit | 422d1098cd89beaeed81cc40488252233e2ca0ad (patch) | |
| tree | 7ea92619f7a76bc797851776cf1901d91b1e458f /installer | |
| parent | ea494c7d0fc41bb1cab888f92408fab29c190e75 (diff) | |
| download | archangel-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.
Diffstat (limited to 'installer')
| -rwxr-xr-x | installer/zfsrollback | 179 | ||||
| -rwxr-xr-x | installer/zfssnapshot | 480 |
2 files changed, 405 insertions, 254 deletions
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 |
