Root cause: under `set -euo pipefail`, the view functions built their body with `body="$(zfs ... | awk ...)"`. If the pipeline exited non-zero — either because `zfs list -t snapshot` returned non-zero (e.g. the no-snapshots case) or because the large report was handed to `whiptail --msgbox` as one argv string and newt choked — `set -e` aborted the entire script, dropping the whole TUI back to the shell. The snapshot view is the most exposed since it is both the largest body and the most likely to hit a non-zero zfs result. Fix: route every list view through a hardened show() that runs the report producer with `set -e` disabled (so an inner non-zero can never kill the TUI) and renders via `whiptail --textbox <tmpfile>` (reads from a file instead of taking the report as an argv string, and scrolls large content cleanly). Each view_* is now a thin wrapper over a _report_* producer that prints to stdout. Verified with a stubbed harness: failing pipeline, 5000-row list, and a non-zero whiptail all now survive; the previous pattern crashed (exit 1). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
717 lines
27 KiB
Bash
Executable File
717 lines
27 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
#
|
|
# zfs-snapshot.sh — Interactive (menuconfig-style) ZFS storage manager for Proxmox VE
|
|
#
|
|
# A whiptail-based TUI to manage ZFS snapshots, scheduled auto-snapshots,
|
|
# datasets, LXC container mountpoints, dataset replication, and pool scrubs
|
|
# on a Proxmox VE host.
|
|
#
|
|
# * Snapshots — list / create / delete / rollback ZFS snapshots
|
|
# * Schedules — cron-driven auto-snapshots with auto-pruning (keep last N)
|
|
# * Datasets — view all ZFS datasets with usage info
|
|
# * LXC Mounts — view / add / remove bind-mountpoints on LXC containers (pct)
|
|
# * Replication — zfs send | recv to another dataset (full or incremental)
|
|
# * Scrub — start / stop / schedule pool scrubs
|
|
#
|
|
# All cron entries managed by this tool live in a single owner file
|
|
# (/etc/cron.d/pve-zfs-tui); your other cron jobs are never touched.
|
|
#
|
|
# Usage: ./zfs-snapshot.sh (re-execs itself with sudo automatically)
|
|
#
|
|
set -euo pipefail
|
|
|
|
APP_TITLE="Proxmox ZFS Manager"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Paths owned by this tool
|
|
# ---------------------------------------------------------------------------
|
|
CRON_FILE="/etc/cron.d/pve-zfs-tui"
|
|
SNAPSHOT_SCRIPT="/usr/local/bin/pve-zfs-snapshot.sh"
|
|
REPLICATION_SCRIPT="/usr/local/bin/pve-zfs-replicate.sh"
|
|
REPLICATION_CONFIG="/etc/pve-zfs-tui/replication.tsv"
|
|
|
|
# Single-owner cron header. Entries below are rewritten in full on every change.
|
|
CRON_HEADER='# Auto-generated by zfs-snapshot.sh — do not edit manually.
|
|
# Manage entries via the Schedules / Scrub menus.
|
|
SHELL=/bin/bash
|
|
PATH=/usr/sbin:/usr/bin:/sbin:/bin
|
|
MAILTO=""'
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Privilege handling: re-exec under sudo so zfs/zpool/pct run as root.
|
|
# ---------------------------------------------------------------------------
|
|
if [[ ${EUID} -ne 0 ]]; then
|
|
echo "Root privileges are required. Re-running with sudo..."
|
|
exec sudo "$0" "$@"
|
|
fi
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Ensure whiptail is available (part of the 'whiptail' package on Debian).
|
|
# ---------------------------------------------------------------------------
|
|
if ! command -v whiptail >/dev/null 2>&1; then
|
|
echo "Installing whiptail (required for the TUI)..."
|
|
apt-get update -qq || true
|
|
apt-get install -y whiptail
|
|
fi
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Generic helpers
|
|
# ---------------------------------------------------------------------------
|
|
msg() { whiptail --title "$APP_TITLE" --msgbox "$1" "${2:-12}" "${3:-72}"; }
|
|
yesno(){ whiptail --title "$APP_TITLE" --yesno "$1" "${2:-12}" "${3:-72}"; }
|
|
|
|
# Display the stdout of a producer command/function in a scrollable box.
|
|
#
|
|
# The producer runs with `set -e` disabled and its exit status discarded, so a
|
|
# non-zero result from zfs / awk / pipefail can never abort the whole TUI — this
|
|
# is what made "View all snapshots" crash back to the shell. The captured report
|
|
# is shown via --textbox (which reads from a file) instead of --msgbox (which
|
|
# takes the entire report as one argv string and chokes on large snapshot lists).
|
|
show() {
|
|
local title="$1"; shift
|
|
local out tmp
|
|
set +e
|
|
out="$("$@" 2>&1)"
|
|
set -e
|
|
tmp="$(mktemp)"
|
|
printf '%s\n' "${out:-(no output)}" >"$tmp"
|
|
whiptail --title "$title" --scrolltext --textbox "$tmp" 24 90 || true
|
|
rm -f "$tmp"
|
|
}
|
|
|
|
# Run a command, capturing output, and show the result in a scrollable box.
|
|
run_and_show() {
|
|
local title="$1"; shift
|
|
local out rc
|
|
set +e
|
|
out="$("$@" 2>&1)"; rc=$?
|
|
set -e
|
|
whiptail --title "$title" --scrolltext --msgbox \
|
|
"$(printf 'Command: %s\nExit code: %s\n\n%s' "$*" "$rc" "${out:-(no output)}")" \
|
|
22 90
|
|
return $rc
|
|
}
|
|
|
|
# Free-text input box. Echoes the entered value, or empty on cancel.
|
|
ask() {
|
|
local prompt="$1" default="${2:-}"
|
|
whiptail --title "$APP_TITLE" --inputbox "$prompt" 11 78 "$default" 3>&1 1>&2 2>&3
|
|
}
|
|
|
|
# Pick one item from a newline-separated list (passed on stdin). The visible
|
|
# tag IS the value. Echoes the chosen value, or empty on cancel / empty list.
|
|
# pick "Prompt text" < <(some_command_producing_lines)
|
|
pick() {
|
|
local prompt="$1"
|
|
local -a items=()
|
|
local line
|
|
while IFS= read -r line; do
|
|
[[ -z "$line" ]] && continue
|
|
items+=("$line" "")
|
|
done
|
|
if [[ ${#items[@]} -eq 0 ]]; then
|
|
msg "Nothing to choose from.\n\n$prompt" 10 70
|
|
return 1
|
|
fi
|
|
whiptail --title "$APP_TITLE" --notags --menu "$prompt" 22 86 12 \
|
|
"${items[@]}" 3>&1 1>&2 2>&3
|
|
}
|
|
|
|
# Timestamp used for snapshot names.
|
|
ts() { date +%Y%m%d-%H%M%S; }
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ZFS primitives
|
|
# ---------------------------------------------------------------------------
|
|
list_pools() { zpool list -H -o name 2>/dev/null; }
|
|
list_datasets() { zfs list -H -o name,used,avail,mountpoint 2>/dev/null; }
|
|
list_dataset_names() { zfs list -H -o name 2>/dev/null; }
|
|
|
|
# All snapshots, one per line: name<TAB>used<TAB>refer<TAB>creation
|
|
list_snapshots() {
|
|
zfs list -t snapshot -H -o name,used,refer,creation 2>/dev/null
|
|
}
|
|
|
|
# Short snapshot names (without the dataset@ prefix) for one exact dataset,
|
|
# oldest first.
|
|
dataset_snapshots() {
|
|
local ds="$1"
|
|
zfs list -H -t snapshot -o name -s creation "$ds" 2>/dev/null \
|
|
| awk -F'@' -v d="$ds" '$1==d {print $2}'
|
|
}
|
|
|
|
latest_snapshot() { dataset_snapshots "$1" | tail -n1; }
|
|
|
|
dataset_exists() { zfs list -H "$1" >/dev/null 2>&1; }
|
|
|
|
# Latest snapshot short-name present on BOTH source and target.
|
|
latest_common_snapshot() {
|
|
local src="$1" tgt="$2" s
|
|
local tgt_snaps; tgt_snaps="$(dataset_snapshots "$tgt")"
|
|
while IFS= read -r s; do
|
|
[[ -z "$s" ]] && continue
|
|
if grep -qxF "$s" <<<"$tgt_snaps"; then echo "$s"; fi
|
|
done < <(dataset_snapshots "$src") | tail -n1
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Snapshots menu
|
|
# ---------------------------------------------------------------------------
|
|
# Report producers print to stdout; show() captures them with `set -e` off.
|
|
_report_snapshots() {
|
|
local rows; rows="$(list_snapshots)"
|
|
[[ -z "$rows" ]] && { echo "(no snapshots found)"; return 0; }
|
|
printf '%s\n' "$rows" | awk -F'\t' \
|
|
'BEGIN{printf "%-48s %8s %8s %s\n","SNAPSHOT","USED","REFER","CREATED"}
|
|
{printf "%-48s %8s %8s %s\n",$1,$2,$3,$4}'
|
|
}
|
|
view_snapshots() { show "ZFS Snapshots" _report_snapshots; }
|
|
|
|
create_snapshot() {
|
|
local ds name
|
|
ds="$(pick "Select a dataset to snapshot:" < <(list_dataset_names))" || return 0
|
|
[[ -z "$ds" ]] && return 0
|
|
name="$(ask "Snapshot name for $ds:" "manual-$(ts)")" || return 0
|
|
[[ -z "$name" ]] && { msg "No name entered." 8 50; return 0; }
|
|
run_and_show "zfs snapshot $ds@$name" zfs snapshot "$ds@$name"
|
|
}
|
|
|
|
delete_snapshot() {
|
|
local snap
|
|
snap="$(pick "Select a snapshot to DESTROY:" < <(list_snapshots | cut -f1))" || return 0
|
|
[[ -z "$snap" ]] && return 0
|
|
yesno "Destroy snapshot:\n\n $snap\n\nThis cannot be undone." || return 0
|
|
run_and_show "zfs destroy $snap" zfs destroy "$snap"
|
|
}
|
|
|
|
rollback_snapshot() {
|
|
local snap
|
|
snap="$(pick "Select a snapshot to ROLL BACK to:" < <(list_snapshots | cut -f1))" || return 0
|
|
[[ -z "$snap" ]] && return 0
|
|
yesno "ROLL BACK to:\n\n $snap\n\nThis DESTROYS all newer snapshots and ALL changes\nmade since then. Continue?" 13 72 || return 0
|
|
run_and_show "zfs rollback -r $snap" zfs rollback -r "$snap"
|
|
}
|
|
|
|
menu_snapshots() {
|
|
local choice
|
|
while true; do
|
|
choice=$(whiptail --title "$APP_TITLE — Snapshots" --notags \
|
|
--menu "Manage ZFS snapshots:" 18 72 7 \
|
|
view "1) View all snapshots" \
|
|
create "2) Create a snapshot" \
|
|
delete "3) Delete a snapshot" \
|
|
rollback "4) Roll back to a snapshot" \
|
|
back "5) Back" \
|
|
3>&1 1>&2 2>&3) || break
|
|
case "$choice" in
|
|
view) view_snapshots ;;
|
|
create) create_snapshot ;;
|
|
delete) delete_snapshot ;;
|
|
rollback) rollback_snapshot ;;
|
|
back) break ;;
|
|
esac
|
|
done
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Cron-backed schedules (snapshots + scrubs share one owner file)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Emit existing snapshot schedules as: dataset<TAB>cron<TAB>keep
|
|
parse_snapshot_schedules() {
|
|
[[ -f "$CRON_FILE" ]] || return 0
|
|
awk -v s="$SNAPSHOT_SCRIPT" '
|
|
/^[[:space:]]*#/ {next} /^[[:space:]]*$/ {next} $1 ~ /=/ {next}
|
|
{ idx=0; for (i=1;i<=NF;i++) if ($i==s) {idx=i; break}
|
|
if (idx) printf "%s\t%s %s %s %s %s\t%s\n", $(idx+1),$1,$2,$3,$4,$5,$(idx+2) }
|
|
' "$CRON_FILE"
|
|
}
|
|
|
|
# Emit existing scrub schedules as: pool<TAB>cron
|
|
parse_scrub_schedules() {
|
|
[[ -f "$CRON_FILE" ]] || return 0
|
|
awk '
|
|
/^[[:space:]]*#/ {next} /^[[:space:]]*$/ {next} $1 ~ /=/ {next}
|
|
{ idx=0; for (i=1;i<=NF;i++) if ($i=="scrub") {idx=i; break}
|
|
if (idx && $(idx-1) ~ /zpool/) printf "%s\t%s %s %s %s %s\n", $(idx+1),$1,$2,$3,$4,$5 }
|
|
' "$CRON_FILE"
|
|
}
|
|
|
|
# Rewrite the whole cron file from the two TSV blobs ($1 snaps, $2 scrubs).
|
|
write_cron_file() {
|
|
local snaps="$1" scrubs="$2" ds cron keep pool
|
|
mkdir -p "$(dirname "$CRON_FILE")"
|
|
{
|
|
printf '%s\n\n' "$CRON_HEADER"
|
|
if [[ -n "$snaps" ]]; then
|
|
echo "# Snapshot schedules (auto-prune keeps last N)"
|
|
while IFS=$'\t' read -r ds cron keep; do
|
|
[[ -z "$ds" ]] && continue
|
|
printf '%s root %s %s %s\n' "$cron" "$SNAPSHOT_SCRIPT" "$ds" "$keep"
|
|
done <<<"$snaps"
|
|
echo
|
|
fi
|
|
if [[ -n "$scrubs" ]]; then
|
|
echo "# Scrub schedules"
|
|
while IFS=$'\t' read -r pool cron; do
|
|
[[ -z "$pool" ]] && continue
|
|
printf '%s root /usr/sbin/zpool scrub %s\n' "$cron" "$pool"
|
|
done <<<"$scrubs"
|
|
echo
|
|
fi
|
|
} >"$CRON_FILE"
|
|
chmod 644 "$CRON_FILE"
|
|
}
|
|
|
|
# Install the standalone snapshot+prune helper invoked by cron.
|
|
install_snapshot_script() {
|
|
cat >"$SNAPSHOT_SCRIPT" <<'EOF'
|
|
#!/bin/bash
|
|
# Auto-generated by zfs-snapshot.sh. Creates an auto-* snapshot and prunes
|
|
# the oldest auto-* snapshots beyond the keep count. Usage: <dataset> [keep]
|
|
DATASET="$1"; KEEP="${2:-7}"; PREFIX="auto"
|
|
[ -z "$DATASET" ] && { echo "Usage: $0 <dataset> [keep_count]"; exit 1; }
|
|
|
|
TS=$(date +%Y%m%d-%H%M%S)
|
|
/usr/sbin/zfs snapshot "${DATASET}@${PREFIX}-${TS}"
|
|
|
|
SNAPS=$(/usr/sbin/zfs list -H -t snapshot -o name -s creation "$DATASET" | grep "@${PREFIX}-")
|
|
COUNT=$(echo "$SNAPS" | grep -c . )
|
|
if [ "$COUNT" -gt "$KEEP" ]; then
|
|
DEL=$((COUNT - KEEP))
|
|
echo "$SNAPS" | head -n "$DEL" | while read -r S; do
|
|
/usr/sbin/zfs destroy "$S"
|
|
done
|
|
fi
|
|
EOF
|
|
chmod 755 "$SNAPSHOT_SCRIPT"
|
|
}
|
|
|
|
# Cron-frequency presets shared by snapshot/scrub scheduling. Echoes a chosen
|
|
# 5-field cron expression, or empty on cancel.
|
|
pick_cron() {
|
|
local kind="$1" choice custom
|
|
if [[ "$kind" == "scrub" ]]; then
|
|
choice=$(whiptail --title "$APP_TITLE" --notags \
|
|
--menu "How often should the scrub run?" 16 72 5 \
|
|
"0 3 1 * *" "Monthly (1st @ 3am)" \
|
|
"0 3 1,15 * *" "Bi-weekly (1st & 15th @ 3am)" \
|
|
"0 3 * * 0" "Weekly (Sunday 3am)" \
|
|
"custom" "Custom cron expression..." \
|
|
3>&1 1>&2 2>&3) || return 1
|
|
else
|
|
choice=$(whiptail --title "$APP_TITLE" --notags \
|
|
--menu "How often should the snapshot run?" 17 72 6 \
|
|
"0 * * * *" "Hourly" \
|
|
"0 */6 * * *" "Every 6 hours" \
|
|
"0 0 * * *" "Daily at midnight" \
|
|
"0 3 * * *" "Daily at 3am" \
|
|
"0 2 * * 0" "Weekly (Sunday 2am)" \
|
|
"custom" "Custom cron expression..." \
|
|
3>&1 1>&2 2>&3) || return 1
|
|
fi
|
|
if [[ "$choice" == "custom" ]]; then
|
|
custom="$(ask "Enter a 5-field cron expression (min hour dom mon dow):" "0 3 * * *")" || return 1
|
|
[[ -z "$custom" ]] && return 1
|
|
echo "$custom"
|
|
else
|
|
echo "$choice"
|
|
fi
|
|
}
|
|
|
|
_report_schedules() {
|
|
local rows; rows="$(parse_snapshot_schedules)"
|
|
[[ -z "$rows" ]] && { echo "(no snapshot schedules configured)"; return 0; }
|
|
printf '%s\n' "$rows" | awk -F'\t' \
|
|
'BEGIN{printf "%-40s %-16s %s\n","DATASET","CRON","KEEP"}
|
|
{printf "%-40s %-16s %s\n",$1,$2,$3}'
|
|
}
|
|
view_schedules() { show "Snapshot Schedules" _report_schedules; }
|
|
|
|
add_schedule() {
|
|
local ds cron keep snaps scrubs
|
|
ds="$(pick "Schedule auto-snapshots for which dataset:" < <(list_dataset_names))" || return 0
|
|
[[ -z "$ds" ]] && return 0
|
|
cron="$(pick_cron snapshot)" || return 0
|
|
keep="$(ask "Keep how many auto snapshots for $ds?" "7")" || return 0
|
|
[[ "$keep" =~ ^[0-9]+$ ]] || keep=7
|
|
|
|
install_snapshot_script
|
|
snaps="$(parse_snapshot_schedules | grep -vP "^${ds}\t" || true)"
|
|
snaps="$(printf '%s\n%s\t%s\t%s' "$snaps" "$ds" "$cron" "$keep")"
|
|
scrubs="$(parse_scrub_schedules)"
|
|
write_cron_file "$snaps" "$scrubs"
|
|
msg "Schedule saved for:\n $ds\n\nCron: $cron Keep: $keep\n\nWritten to $CRON_FILE" 13 72
|
|
}
|
|
|
|
snapshot_now() {
|
|
local row ds keep
|
|
row="$(pick "Take an auto-snapshot now for which scheduled dataset:" \
|
|
< <(parse_snapshot_schedules | cut -f1))" || return 0
|
|
[[ -z "$row" ]] && return 0
|
|
ds="$row"
|
|
keep="$(parse_snapshot_schedules | awk -F'\t' -v d="$ds" '$1==d {print $3; exit}')"
|
|
[[ "$keep" =~ ^[0-9]+$ ]] || keep=7
|
|
install_snapshot_script
|
|
run_and_show "snapshot now: $ds (keep $keep)" "$SNAPSHOT_SCRIPT" "$ds" "$keep"
|
|
}
|
|
|
|
remove_schedule() {
|
|
local ds snaps scrubs
|
|
ds="$(pick "Remove the snapshot schedule for which dataset:" \
|
|
< <(parse_snapshot_schedules | cut -f1))" || return 0
|
|
[[ -z "$ds" ]] && return 0
|
|
yesno "Remove auto-snapshot schedule for:\n $ds ?" || return 0
|
|
snaps="$(parse_snapshot_schedules | grep -vP "^${ds}\t" || true)"
|
|
scrubs="$(parse_scrub_schedules)"
|
|
write_cron_file "$snaps" "$scrubs"
|
|
msg "Removed schedule for $ds." 8 60
|
|
}
|
|
|
|
menu_schedules() {
|
|
local choice
|
|
while true; do
|
|
choice=$(whiptail --title "$APP_TITLE — Schedules" --notags \
|
|
--menu "Auto-snapshot schedules (cron):" 18 74 7 \
|
|
view "1) View schedules" \
|
|
add "2) Add / update a schedule" \
|
|
now "3) Snapshot a scheduled dataset NOW" \
|
|
remove "4) Remove a schedule" \
|
|
back "5) Back" \
|
|
3>&1 1>&2 2>&3) || break
|
|
case "$choice" in
|
|
view) view_schedules ;;
|
|
add) add_schedule ;;
|
|
now) snapshot_now ;;
|
|
remove) remove_schedule ;;
|
|
back) break ;;
|
|
esac
|
|
done
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Datasets menu
|
|
# ---------------------------------------------------------------------------
|
|
_report_datasets() {
|
|
local rows; rows="$(list_datasets)"
|
|
[[ -z "$rows" ]] && { echo "(no datasets found — is 'zfs list' working as root?)"; return 0; }
|
|
printf '%s\n' "$rows" | awk -F'\t' \
|
|
'BEGIN{printf "%-40s %8s %8s %s\n","NAME","USED","AVAIL","MOUNTPOINT"}
|
|
{printf "%-40s %8s %8s %s\n",$1,$2,$3,$4}'
|
|
}
|
|
view_datasets() { show "ZFS Datasets" _report_datasets; }
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# LXC mountpoints menu (Proxmox pct)
|
|
# ---------------------------------------------------------------------------
|
|
have_pct() { command -v pct >/dev/null 2>&1; }
|
|
|
|
# Container IDs, one per line.
|
|
list_lxc_ids() { pct list 2>/dev/null | awk 'NR>1 {print $1}'; }
|
|
|
|
# Mountpoints for one container: slot<TAB>hostpath<TAB>ctpath
|
|
lxc_mountpoints() {
|
|
local ctid="$1"
|
|
pct config "$ctid" 2>/dev/null | awk '
|
|
/^mp[0-9]+:/ {
|
|
key=$1; sub(":","",key)
|
|
line=$0; sub(/^mp[0-9]+:[[:space:]]*/,"",line)
|
|
n=split(line,a,","); host=a[1]; target=""
|
|
for (i=2;i<=n;i++) if (a[i] ~ /^mp=/) { target=substr(a[i],4) }
|
|
printf "%s\t%s\t%s\n", key, host, target
|
|
}'
|
|
}
|
|
|
|
_report_mounts() {
|
|
local ctid slot host target found=0
|
|
while IFS= read -r ctid; do
|
|
[[ -z "$ctid" ]] && continue
|
|
while IFS=$'\t' read -r slot host target; do
|
|
[[ -z "$slot" ]] && continue
|
|
printf 'CT %-6s %-6s %-30s -> %s\n' "$ctid" "$slot" "$host" "$target"
|
|
found=1
|
|
done < <(lxc_mountpoints "$ctid")
|
|
done < <(list_lxc_ids)
|
|
[[ "$found" -eq 0 ]] && echo "(no LXC mountpoints found)"
|
|
return 0
|
|
}
|
|
view_mounts() { show "LXC Mountpoints" _report_mounts; }
|
|
|
|
add_mount() {
|
|
local ctid host target used slot=0
|
|
ctid="$(pick "Add a mountpoint to which container:" < <(list_lxc_ids))" || return 0
|
|
[[ -z "$ctid" ]] && return 0
|
|
host="$(ask "Host path on the Proxmox host (e.g. /RAID1_1TB/MyData):")" || return 0
|
|
[[ -z "$host" ]] && return 0
|
|
target="$(ask "Mount path inside the container (e.g. /data):")" || return 0
|
|
[[ -z "$target" ]] && return 0
|
|
|
|
used="$(lxc_mountpoints "$ctid" | sed -n 's/^mp\([0-9]\+\).*/\1/p')"
|
|
while grep -qx "$slot" <<<"$used"; do slot=$((slot + 1)); done
|
|
run_and_show "pct set $ctid -mp$slot" pct set "$ctid" "-mp${slot}" "${host},mp=${target}"
|
|
}
|
|
|
|
remove_mount() {
|
|
local ctid slot
|
|
ctid="$(pick "Remove a mountpoint from which container:" < <(list_lxc_ids))" || return 0
|
|
[[ -z "$ctid" ]] && return 0
|
|
slot="$(pick "Which mountpoint slot to remove from CT $ctid:" \
|
|
< <(lxc_mountpoints "$ctid" | awk -F'\t' '{print $1" ("$2" -> "$3")"}' | sed 's/ .*//'))" || return 0
|
|
[[ -z "$slot" ]] && return 0
|
|
yesno "Remove $slot from container $ctid?\n\nThe container may need to be stopped for this to apply." || return 0
|
|
run_and_show "pct set $ctid -delete $slot" pct set "$ctid" -delete "$slot"
|
|
}
|
|
|
|
menu_mounts() {
|
|
if ! have_pct; then
|
|
msg "'pct' not found — LXC mountpoint management needs Proxmox VE.\n\nThis menu is unavailable on this host." 10 72
|
|
return 0
|
|
fi
|
|
local choice
|
|
while true; do
|
|
choice=$(whiptail --title "$APP_TITLE — LXC Mounts" --notags \
|
|
--menu "LXC container bind-mountpoints (pct):" 17 74 6 \
|
|
view "1) View mountpoints" \
|
|
add "2) Add a mountpoint" \
|
|
remove "3) Remove a mountpoint" \
|
|
back "4) Back" \
|
|
3>&1 1>&2 2>&3) || break
|
|
case "$choice" in
|
|
view) view_mounts ;;
|
|
add) add_mount ;;
|
|
remove) remove_mount ;;
|
|
back) break ;;
|
|
esac
|
|
done
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Replication menu (zfs send | recv)
|
|
# ---------------------------------------------------------------------------
|
|
load_replication() { [[ -f "$REPLICATION_CONFIG" ]] && cat "$REPLICATION_CONFIG"; return 0; }
|
|
|
|
save_replication() {
|
|
mkdir -p "$(dirname "$REPLICATION_CONFIG")"
|
|
printf '%s' "$1" >"$REPLICATION_CONFIG"
|
|
}
|
|
|
|
# Standalone replication helper for cron / manual use.
|
|
install_replication_script() {
|
|
cat >"$REPLICATION_SCRIPT" <<'EOF'
|
|
#!/bin/bash
|
|
# Auto-generated by zfs-snapshot.sh. Incrementally replicates a ZFS dataset.
|
|
# Usage: pve-zfs-replicate.sh <source-dataset> <target-dataset>
|
|
set -e
|
|
SOURCE="$1"; TARGET="$2"
|
|
[ -z "$SOURCE" ] || [ -z "$TARGET" ] && { echo "Usage: $0 <source> <target>"; exit 1; }
|
|
|
|
LATEST=$(/usr/sbin/zfs list -H -t snapshot -o name -s creation "$SOURCE" | tail -n1 | awk -F@ '{print $2}')
|
|
[ -z "$LATEST" ] && { echo "No snapshots on $SOURCE"; exit 1; }
|
|
|
|
if /usr/sbin/zfs list -H "$TARGET" >/dev/null 2>&1; then
|
|
COMMON=""
|
|
for s in $(/usr/sbin/zfs list -H -t snapshot -o name -s creation "$SOURCE" | awk -F@ '{print $2}' | tac); do
|
|
if /usr/sbin/zfs list -H -t snapshot "${TARGET}@${s}" >/dev/null 2>&1; then COMMON="$s"; break; fi
|
|
done
|
|
[ -z "$COMMON" ] && { echo "No common snapshot between $SOURCE and $TARGET; aborting." >&2; exit 2; }
|
|
if [ "$COMMON" = "$LATEST" ]; then echo "Up-to-date: $SOURCE@$COMMON"; exit 0; fi
|
|
/usr/sbin/zfs send -R -I "${SOURCE}@${COMMON}" "${SOURCE}@${LATEST}" | /usr/sbin/zfs receive -F "$TARGET"
|
|
echo "Incremental ${COMMON} -> ${LATEST}"
|
|
else
|
|
/usr/sbin/zfs send -R "${SOURCE}@${LATEST}" | /usr/sbin/zfs receive "$TARGET"
|
|
echo "Full -> ${LATEST}"
|
|
fi
|
|
EOF
|
|
chmod 755 "$REPLICATION_SCRIPT"
|
|
}
|
|
|
|
_report_replication() {
|
|
local src tgt common latest status found=0
|
|
while IFS=$'\t' read -r src tgt; do
|
|
[[ -z "$src" ]] && continue
|
|
found=1
|
|
latest="$(latest_snapshot "$src")"; latest="${latest:--}"
|
|
common="$(latest_common_snapshot "$src" "$tgt")"; common="${common:--}"
|
|
if ! dataset_exists "$tgt"; then status="target missing (full send needed)"
|
|
elif [[ "$common" == "$latest" && "$common" != "-" ]]; then status="up-to-date"
|
|
elif [[ "$common" == "-" ]]; then status="no common snap (mismatch)"
|
|
else status="incremental pending"; fi
|
|
printf '%-30s -> %-30s [%s]\n' "$src" "$tgt" "$status"
|
|
done < <(load_replication)
|
|
[[ "$found" -eq 0 ]] && echo "(no replication targets configured)"
|
|
return 0
|
|
}
|
|
view_replication() { show "Replication Targets" _report_replication; }
|
|
|
|
add_replication() {
|
|
local src tgt cfg
|
|
src="$(pick "Replicate which source dataset:" < <(list_dataset_names))" || return 0
|
|
[[ -z "$src" ]] && return 0
|
|
tgt="$(ask "Target dataset (must be on an imported pool):" "backup/${src}")" || return 0
|
|
[[ -z "$tgt" ]] && return 0
|
|
cfg="$(load_replication)"
|
|
if grep -qxF "$(printf '%s\t%s' "$src" "$tgt")" <<<"$cfg"; then
|
|
msg "That target is already configured." 8 60; return 0
|
|
fi
|
|
cfg="$(printf '%s%s\t%s\n' "${cfg:+$cfg$'\n'}" "$src" "$tgt")"
|
|
save_replication "$cfg"
|
|
install_replication_script
|
|
msg "Added replication target:\n $src -> $tgt" 9 72
|
|
}
|
|
|
|
run_replication() {
|
|
local pair src tgt
|
|
pair="$(pick "Replicate which target now:" \
|
|
< <(load_replication | awk -F'\t' '{print $1" -> "$2}'))" || return 0
|
|
[[ -z "$pair" ]] && return 0
|
|
src="${pair%% -> *}"; tgt="${pair##* -> }"
|
|
install_replication_script
|
|
msg "Replicating $src -> $tgt ...\n\nThis may take a while; press OK to start." 9 72
|
|
run_and_show "replicate $src -> $tgt" "$REPLICATION_SCRIPT" "$src" "$tgt"
|
|
}
|
|
|
|
remove_replication() {
|
|
local pair src tgt cfg
|
|
pair="$(pick "Remove which replication target:" \
|
|
< <(load_replication | awk -F'\t' '{print $1" -> "$2}'))" || return 0
|
|
[[ -z "$pair" ]] && return 0
|
|
src="${pair%% -> *}"; tgt="${pair##* -> }"
|
|
yesno "Remove replication target?\n $src -> $tgt\n\n(The target dataset itself is NOT destroyed.)" || return 0
|
|
cfg="$(load_replication | grep -vxF "$(printf '%s\t%s' "$src" "$tgt")" || true)"
|
|
save_replication "$cfg"
|
|
msg "Removed: $src -> $tgt" 8 60
|
|
}
|
|
|
|
menu_replication() {
|
|
local choice
|
|
while true; do
|
|
choice=$(whiptail --title "$APP_TITLE — Replication" --notags \
|
|
--menu "Replicate datasets via zfs send | recv:" 18 74 7 \
|
|
view "1) View targets & status" \
|
|
add "2) Add a target" \
|
|
run "3) Replicate a target NOW" \
|
|
remove "4) Remove a target" \
|
|
back "5) Back" \
|
|
3>&1 1>&2 2>&3) || break
|
|
case "$choice" in
|
|
view) view_replication ;;
|
|
add) add_replication ;;
|
|
run) run_replication ;;
|
|
remove) remove_replication ;;
|
|
back) break ;;
|
|
esac
|
|
done
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Scrub menu
|
|
# ---------------------------------------------------------------------------
|
|
_report_scrub() {
|
|
local pool sched found=0
|
|
while IFS= read -r pool; do
|
|
[[ -z "$pool" ]] && continue
|
|
found=1
|
|
sched="$(parse_scrub_schedules | awk -F'\t' -v p="$pool" '$1==p {print $2; exit}')"
|
|
printf '===== %s (schedule: %s) =====\n' "$pool" "${sched:-none}"
|
|
zpool status "$pool" 2>&1 | grep -E '^[[:space:]]*(state|scan|errors):' || true
|
|
echo
|
|
done < <(list_pools)
|
|
[[ "$found" -eq 0 ]] && echo "(no pools found)"
|
|
return 0
|
|
}
|
|
view_scrub() { show "Pool Scrub Status" _report_scrub; }
|
|
|
|
scrub_start() {
|
|
local pool
|
|
pool="$(pick "Start a scrub on which pool:" < <(list_pools))" || return 0
|
|
[[ -z "$pool" ]] && return 0
|
|
run_and_show "zpool scrub $pool" zpool scrub "$pool"
|
|
}
|
|
|
|
scrub_stop() {
|
|
local pool
|
|
pool="$(pick "Stop the running scrub on which pool:" < <(list_pools))" || return 0
|
|
[[ -z "$pool" ]] && return 0
|
|
run_and_show "zpool scrub -s $pool" zpool scrub -s "$pool"
|
|
}
|
|
|
|
scrub_schedule() {
|
|
local pool cron snaps scrubs
|
|
pool="$(pick "Schedule scrubs for which pool:" < <(list_pools))" || return 0
|
|
[[ -z "$pool" ]] && return 0
|
|
cron="$(pick_cron scrub)" || return 0
|
|
snaps="$(parse_snapshot_schedules)"
|
|
scrubs="$(parse_scrub_schedules | grep -vP "^${pool}\t" || true)"
|
|
scrubs="$(printf '%s\n%s\t%s' "$scrubs" "$pool" "$cron")"
|
|
write_cron_file "$snaps" "$scrubs"
|
|
msg "Scrub scheduled for $pool.\n\nCron: $cron\nWritten to $CRON_FILE" 11 72
|
|
}
|
|
|
|
scrub_unschedule() {
|
|
local pool snaps scrubs
|
|
pool="$(pick "Remove the scrub schedule for which pool:" \
|
|
< <(parse_scrub_schedules | cut -f1))" || return 0
|
|
[[ -z "$pool" ]] && return 0
|
|
yesno "Remove scrub schedule for pool $pool?" || return 0
|
|
snaps="$(parse_snapshot_schedules)"
|
|
scrubs="$(parse_scrub_schedules | grep -vP "^${pool}\t" || true)"
|
|
write_cron_file "$snaps" "$scrubs"
|
|
msg "Removed scrub schedule for $pool." 8 60
|
|
}
|
|
|
|
menu_scrub() {
|
|
local choice
|
|
while true; do
|
|
choice=$(whiptail --title "$APP_TITLE — Scrub" --notags \
|
|
--menu "Pool scrub management:" 18 72 8 \
|
|
view "1) View scrub status" \
|
|
start "2) Start a scrub" \
|
|
stop "3) Stop a running scrub" \
|
|
schedule "4) Schedule periodic scrubs" \
|
|
unschedule "5) Remove a scrub schedule" \
|
|
back "6) Back" \
|
|
3>&1 1>&2 2>&3) || break
|
|
case "$choice" in
|
|
view) view_scrub ;;
|
|
start) scrub_start ;;
|
|
stop) scrub_stop ;;
|
|
schedule) scrub_schedule ;;
|
|
unschedule) scrub_unschedule ;;
|
|
back) break ;;
|
|
esac
|
|
done
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Main menu loop
|
|
# ---------------------------------------------------------------------------
|
|
main_menu() {
|
|
local choice
|
|
while true; do
|
|
choice=$(whiptail --title "$APP_TITLE" --notags \
|
|
--menu "Select an area (host: $(hostname))" 20 74 9 \
|
|
snapshots "1) Snapshots — list / create / delete / rollback" \
|
|
schedules "2) Schedules — cron auto-snapshots (keep last N)" \
|
|
datasets "3) Datasets — view ZFS datasets & usage" \
|
|
mounts "4) LXC Mounts — manage container bind-mounts" \
|
|
replication "5) Replication — zfs send | recv to another dataset" \
|
|
scrub "6) Scrub — start / stop / schedule pool scrubs" \
|
|
quit "7) Quit" \
|
|
3>&1 1>&2 2>&3) || break
|
|
|
|
case "$choice" in
|
|
snapshots) menu_snapshots ;;
|
|
schedules) menu_schedules ;;
|
|
datasets) view_datasets ;;
|
|
mounts) menu_mounts ;;
|
|
replication) menu_replication ;;
|
|
scrub) menu_scrub ;;
|
|
quit) break ;;
|
|
esac
|
|
done
|
|
clear
|
|
echo "Done."
|
|
}
|
|
|
|
main_menu
|