diff options
| -rw-r--r-- | Makefile | 2 | ||||
| -rw-r--r-- | README.org | 18 | ||||
| -rwxr-xr-x | build.sh | 8 | ||||
| -rwxr-xr-x | installer/zfsrollback | 179 | ||||
| -rwxr-xr-x | installer/zfssnapshot | 480 | ||||
| -rwxr-xr-x | scripts/sanity-test.sh | 6 | ||||
| -rw-r--r-- | testing-strategy.org | 1 | ||||
| -rw-r--r-- | tests/unit/test_zfssnapshot.bats | 153 |
8 files changed, 573 insertions, 274 deletions
@@ -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 @@ -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 @@ -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:"* ]] +} |
