aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-14 08:41:16 -0500
committerCraig Jennings <c@cjennings.net>2026-05-14 08:41:16 -0500
commiteada697a11da5db8446108fed7573af809d222cc (patch)
tree93eb06fcdf16496167d342502984411236b20df9
parent659e90ad1b85eddee4b1d64afbcc0b1e4e8eef9f (diff)
downloadarchangel-eada697a11da5db8446108fed7573af809d222cc.tar.gz
archangel-eada697a11da5db8446108fed7573af809d222cc.zip
feat: add --name flag to zfssnapshot rollback and delete
I added --name NAME to rollback (single name) and --name NAME[,NAME...] to delete (comma-separated for multi-select) so scripted callers can drive the wrapper without fzf. The upcoming VM verification step in scripts/test-install.sh needs this. fzf is now conditional, required only when --name is omitted. The 10 new bats tests cover help-text mentions, parse success and failure modes (missing value, mutex with -s, unknown flag), fzf-bypass on both subcommands, and multi-name expansion on delete.
-rwxr-xr-xinstaller/zfssnapshot111
-rw-r--r--tests/unit/test_zfssnapshot.bats133
2 files changed, 212 insertions, 32 deletions
diff --git a/installer/zfssnapshot b/installer/zfssnapshot
index 88315a9..f9dfb43 100755
--- a/installer/zfssnapshot
+++ b/installer/zfssnapshot
@@ -22,13 +22,19 @@ Subcommands:
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
+ rollback [-s] [--name NAME]
+ 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
+ only the selected dataset. --name NAME
+ bypasses fzf for scripted callers (mutually
+ exclusive with -s).
+ delete [--name NAME[,NAME...]]
+ fzf multi-select snapshots to destroy. By
default, destroys the snapshot across ALL
- matching datasets.
+ matching datasets. --name NAME[,NAME...]
+ bypasses fzf and destroys the named
+ snapshots (comma-separated for multi-select).
Common options:
-h, --help Show this help and exit.
@@ -165,17 +171,33 @@ cmd_create() {
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 ;;
+ local name_arg=""
+ while [[ $# -gt 0 ]]; do
+ case "$1" in
+ -h|--help) show_help; exit 0 ;;
+ -s) single_mode=true; shift ;;
+ --name)
+ if [[ -z "${2:-}" || "$2" == -* ]]; then
+ echo "Error: --name requires an argument" >&2
+ exit 1
+ fi
+ name_arg="$2"
+ shift 2
+ ;;
+ *) echo "Invalid option: $1" >&2; exit 1 ;;
esac
done
+ if [[ -n "$name_arg" && "$single_mode" == "true" ]]; then
+ echo "Error: --name and -s cannot be combined" >&2
+ exit 1
+ fi
+
+ # fzf is only required for interactive selection; --name skips it.
+ [[ -z "$name_arg" ]] && require_fzf
+
local snapshots
snapshots=$(zfs list -t snapshot -H -o name 2>/dev/null)
@@ -205,17 +227,21 @@ cmd_rollback() {
# 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
+ if [[ -n "$name_arg" ]]; then
+ snap_name="$name_arg"
+ else
+ 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
fi
mapfile -t targets < <(echo "$snapshots" | grep "@${snap_name}$" \
@@ -305,7 +331,24 @@ cmd_rollback() {
cmd_delete() {
require_root
require_zfs
- require_fzf
+
+ local name_arg=""
+ while [[ $# -gt 0 ]]; do
+ case "$1" in
+ -h|--help) show_help; exit 0 ;;
+ --name)
+ if [[ -z "${2:-}" || "$2" == -* ]]; then
+ echo "Error: --name requires an argument" >&2
+ exit 1
+ fi
+ name_arg="$2"
+ shift 2
+ ;;
+ *) echo "Invalid option: $1" >&2; exit 1 ;;
+ esac
+ done
+
+ [[ -z "$name_arg" ]] && require_fzf
local snapshots
snapshots=$(zfs list -t snapshot -H -o name 2>/dev/null)
@@ -318,18 +361,22 @@ cmd_delete() {
# 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)
-
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)
+ if [[ -n "$name_arg" ]]; then
+ selected_names=$(echo "$name_arg" | tr ',' '\n')
+ else
+ local unique_snaps
+ unique_snaps=$(echo "$snapshots" | sed 's/.*@//' | sort -ru)
- if [ -z "$selected_names" ]; then
- echo "No snapshots selected, exiting"
- exit 0
+ 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)
+
+ if [ -z "$selected_names" ]; then
+ echo "No snapshots selected, exiting"
+ exit 0
+ fi
fi
# Expand selected names to full dataset@name targets.
diff --git a/tests/unit/test_zfssnapshot.bats b/tests/unit/test_zfssnapshot.bats
index 74e13cc..dc06483 100644
--- a/tests/unit/test_zfssnapshot.bats
+++ b/tests/unit/test_zfssnapshot.bats
@@ -151,3 +151,136 @@ setup() {
[[ "$output" == *"not-a-subcommand"* ]]
[[ "$output" == *"Usage:"* ]]
}
+
+#############################
+# show_help — flag documentation
+#############################
+
+@test "show_help documents --name for rollback" {
+ run show_help
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"rollback"* ]]
+ [[ "$output" == *"--name NAME"* ]]
+}
+
+@test "show_help documents --name for delete" {
+ run show_help
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"delete"* ]]
+ [[ "$output" == *"--name NAME[,NAME...]"* ]]
+}
+
+#############################
+# cmd_rollback --name
+#############################
+# These tests mock require_*, fzf, and zfs to exercise the arg-parse +
+# fzf-bypass path without needing a real ZFS system. The destructive
+# tail (zfs rollback) is exercised in scripts/test-install.sh against a
+# real VM. Confirmation always answered "no" so the test stops at the
+# gate without reaching the rollback step.
+
+@test "cmd_rollback --name bypasses fzf and reaches the confirmation gate" {
+ require_root() { :; }
+ require_zfs() { :; }
+ require_fzf() { echo "FZF REQUIRED" >&2; return 1; }
+ fzf() { echo "FZF INVOKED" >&2; return 1; }
+ zfs() {
+ case "$1" in
+ list) printf 'zroot/ROOT/default@target\nzroot/home@target\n' ;;
+ *) echo "zfs $*" ;;
+ esac
+ }
+
+ run cmd_rollback --name target <<< "no"
+
+ [ "$status" -eq 0 ]
+ [[ "$output" != *"FZF REQUIRED"* ]]
+ [[ "$output" != *"FZF INVOKED"* ]]
+ [[ "$output" == *"@target"* ]]
+ [[ "$output" == *"cancelled"* ]]
+}
+
+@test "cmd_rollback --name without a value errors out" {
+ require_root() { :; }
+ require_zfs() { :; }
+ run cmd_rollback --name
+ [ "$status" -ne 0 ]
+ [[ "$output" == *"requires an argument"* ]]
+}
+
+@test "cmd_rollback --name combined with -s errors out" {
+ require_root() { :; }
+ require_zfs() { :; }
+ run cmd_rollback --name foo -s
+ [ "$status" -ne 0 ]
+ [[ "$output" == *"cannot be combined"* ]]
+}
+
+@test "cmd_rollback rejects an unknown flag" {
+ require_root() { :; }
+ require_zfs() { :; }
+ run cmd_rollback --bogus
+ [ "$status" -ne 0 ]
+ [[ "$output" == *"Invalid option"* ]]
+}
+
+#############################
+# cmd_delete --name
+#############################
+
+@test "cmd_delete --name bypasses fzf and reaches the confirmation gate" {
+ require_root() { :; }
+ require_zfs() { :; }
+ require_fzf() { echo "FZF REQUIRED" >&2; return 1; }
+ fzf() { echo "FZF INVOKED" >&2; return 1; }
+ zfs() {
+ case "$1" in
+ list) printf 'zroot/ROOT/default@target\nzroot/home@target\n' ;;
+ *) echo "zfs $*" ;;
+ esac
+ }
+
+ run cmd_delete --name target <<< "no"
+
+ [ "$status" -eq 0 ]
+ [[ "$output" != *"FZF REQUIRED"* ]]
+ [[ "$output" != *"FZF INVOKED"* ]]
+ [[ "$output" == *"@target"* ]]
+ [[ "$output" == *"cancelled"* ]]
+}
+
+@test "cmd_delete --name expands comma-separated names to multiple targets" {
+ require_root() { :; }
+ require_zfs() { :; }
+ require_fzf() { :; }
+ zfs() {
+ case "$1" in
+ list) printf 'zroot/ROOT/default@a\nzroot/home@a\nzroot/ROOT/default@b\n' ;;
+ *) echo "zfs $*" ;;
+ esac
+ }
+
+ run cmd_delete --name a,b <<< "no"
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"zroot/ROOT/default@a"* ]]
+ [[ "$output" == *"zroot/home@a"* ]]
+ [[ "$output" == *"zroot/ROOT/default@b"* ]]
+ [[ "$output" == *"3 snapshot(s) will be destroyed"* ]]
+}
+
+@test "cmd_delete --name without a value errors out" {
+ require_root() { :; }
+ require_zfs() { :; }
+ run cmd_delete --name
+ [ "$status" -ne 0 ]
+ [[ "$output" == *"requires an argument"* ]]
+}
+
+@test "cmd_delete rejects an unknown flag" {
+ require_root() { :; }
+ require_zfs() { :; }
+ run cmd_delete --bogus
+ [ "$status" -ne 0 ]
+ [[ "$output" == *"Invalid option"* ]]
+}