diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d38c149 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +*.swp +*~ diff --git a/README.md b/README.md index c7793bf..521d6e4 100644 --- a/README.md +++ b/README.md @@ -1,85 +1,78 @@ -# PVE Storage Manager TUI +# proxmox-zfs-snapshot-management-script -Python Textual-based TUI for managing ZFS snapshots and LXC mountpoints on Proxmox VE. +An interactive, **menuconfig-style** (whiptail TUI) script to manage ZFS +snapshots and related storage tasks on a **Proxmox VE** host — snapshots, +scheduled auto-snapshots, datasets, LXC bind-mountpoints, dataset replication, +and pool scrubs — all from one keyboard-driven menu. ## Features -- **Snapshots tab** — List, create, delete, rollback ZFS snapshots across all pools -- **Schedules tab** — Set up auto-snapshot cron jobs per dataset with auto-pruning (keep last N) -- **Datasets tab** — View all ZFS datasets with usage info -- **LXC Mounts tab** — View, add, and remove mountpoints for LXC containers +- **Snapshots** — list, **create**, **delete**, and **roll back** ZFS snapshots + (rollback uses `zfs rollback -r`, with a clear destructive-action warning). +- **Schedules** — cron-driven **auto-snapshots** per dataset with auto-pruning + (keep last *N*). Includes a *"snapshot now"* action that reuses the same + helper. Frequency presets (hourly … weekly) plus a custom cron expression. +- **Datasets** — view all ZFS datasets with `used` / `available` / mountpoint. +- **LXC Mounts** — view, **add**, and **remove** bind-mountpoints on LXC + containers via `pct` (auto-picks the next free `mpN` slot). +- **Replication** — `zfs send | recv` a dataset to another dataset, choosing + **full** vs **incremental** automatically based on the latest common snapshot. +- **Scrub** — start / stop a scrub and **schedule** periodic scrubs per pool. -## Installation +All cron entries this tool manages live in a **single owner file** +(`/etc/cron.d/pve-zfs-tui`), rewritten in full on every change — your other +cron jobs are never touched. Two small helper scripts are installed on demand: -Run on **Proxmox host as root**. +| Path | Purpose | +|------|---------| +| `/usr/local/bin/pve-zfs-snapshot.sh` | snapshot + prune (run by snapshot cron) | +| `/usr/local/bin/pve-zfs-replicate.sh` | full/incremental replication (manual or cron) | +| `/etc/pve-zfs-tui/replication.tsv` | configured replication source→target pairs | -### 1. Install dependencies +## Requirements + +- **Proxmox VE** host (Debian-based) with ZFS — provides `zfs`, `zpool`, and + (for the LXC menu) `pct`. The non-Proxmox menus work on any ZFS-on-Linux box; + the **LXC Mounts** menu is hidden when `pct` is absent. +- `root` — the script re-execs itself with `sudo` automatically. +- `whiptail` (installed automatically if missing). + +## Usage ```bash -apt update -apt install -y python3-pip -pip3 install textual --break-system-packages +chmod +x zfs-snapshot.sh +./zfs-snapshot.sh ``` -### 2. Copy the script +The main menu lets you drill into each area; every destructive action +(delete, rollback, schedule removal, mount removal) asks for confirmation, +and every command shows its output and exit code in a scrollable box. -```bash -# Copy pve_tui.py to the Proxmox host, then: -chmod +x /root/pve_tui.py -``` +### Scheduling an auto-snapshot -### 3. Run +1. **Schedules → Add / update a schedule**. +2. Pick the dataset, choose a frequency (or enter a custom cron expression), + and set **keep last N**. +3. The schedule is written to `/etc/cron.d/pve-zfs-tui`, e.g.: -```bash -python3 /root/pve_tui.py -``` - -### Optional: make it a command - -```bash -cp /root/pve_tui.py /usr/local/bin/pve-tui -chmod +x /usr/local/bin/pve-tui -``` - -Then just run `pve-tui` from anywhere. - -## Keybindings - -| Key | Action | -|-----|--------| -| `q` | Quit | -| `r` | Refresh current view | -| `n` | New snapshot | -| `d` | Delete selected snapshot | -| `s` | Add schedule | -| `Tab` / arrow keys | Navigate tabs | -| `↑` / `↓` | Move selection in table | - -## How Scheduling Works - -When you create a schedule, the tool: - -1. Installs a helper script at `/usr/local/bin/pve-tui-snapshot.sh` -2. Adds a cron entry in root's crontab between markers: + ```cron + # Snapshot schedules (auto-prune keeps last N) + 0 3 * * * root /usr/local/bin/pve-zfs-snapshot.sh RAID1_1TB/data 7 ``` - # === PVE-TUI AUTO SNAPSHOT BEGIN === - 0 3 * * * /usr/local/bin/pve-tui-snapshot.sh RAID1_1TB 7 - # === PVE-TUI AUTO SNAPSHOT END === - ``` -3. On each run, creates `DATASET@auto-YYYYMMDD-HHMMSS` and prunes - oldest `auto-*` snapshots beyond the "keep last N" limit. -Manual snapshots (any name not starting with `auto-`) are **never** auto-pruned. + On each run it creates `DATASET@auto-YYYYMMDD-HHMMSS` and destroys the + oldest `auto-*` snapshots beyond the keep limit. Manual snapshots (any name + not starting with `auto-`) are **never** auto-pruned. -## Safety Notes +## Safety notes -- **Rollback** destroys all newer snapshots. Always double-check before confirming. -- **LXC mount changes** may require the container to be stopped (Proxmox will warn). -- Crontab is only modified inside the marker block — your other cron entries are untouched. -- The script uses `zfs`, `pct`, and `crontab` — no third-party ZFS tools required. +- **Rollback** destroys all newer snapshots and changes since the chosen + snapshot. The TUI warns before confirming. +- **LXC mount changes** may require the container to be stopped (Proxmox warns). +- Removing a **replication target** only forgets the pair — it does *not* + destroy the target dataset. +- The cron file is owned entirely by this tool; edit schedules via the menus. -## Troubleshooting +## License -- **"No datasets found"** → check `zfs list` works as root -- **Cron not running** → check `systemctl status cron` and `/var/log/syslog` -- **Textual rendering issues** → use a modern terminal (Alacritty, iTerm2, Windows Terminal) +MIT diff --git a/session b/session deleted file mode 100644 index 83ea14a..0000000 --- a/session +++ /dev/null @@ -1 +0,0 @@ -claude --resume 67e13bd7-317d-4f76-a3cd-524f2eab26d8 diff --git a/tui.py b/tui.py deleted file mode 100644 index d9311b5..0000000 --- a/tui.py +++ /dev/null @@ -1,1346 +0,0 @@ -#!/usr/bin/env python3 -""" -PVE Storage Manager TUI -Manages ZFS snapshots, scheduling, and LXC container mountpoints -on RAID1_1TB and Samsung_860_EVO_256GB pools. - -Run on Proxmox host as root. -""" - -import subprocess -import re -import json -import shlex -from datetime import datetime -from pathlib import Path - -from textual import work -from textual.app import App, ComposeResult -from textual.containers import Container, Horizontal, Vertical -from textual.screen import ModalScreen -from textual.widgets import ( - Header, - Footer, - DataTable, - Button, - Static, - Input, - Label, - Select, - TabbedContent, - TabPane, -) -from textual.binding import Binding - - -# ----------------------- ZFS helpers ----------------------- - -def run(cmd: list[str]) -> tuple[int, str, str]: - """Run a shell command and return (rc, stdout, stderr).""" - try: - r = subprocess.run(cmd, capture_output=True, text=True, timeout=30) - return r.returncode, r.stdout.strip(), r.stderr.strip() - except Exception as e: - return 1, "", str(e) - - -def list_pools() -> list[str]: - rc, out, _ = run(["zpool", "list", "-H", "-o", "name"]) - if rc != 0: - return [] - return out.splitlines() - - -def list_datasets(pool: str | None = None) -> list[dict]: - args = ["zfs", "list", "-H", "-o", "name,used,avail,mountpoint"] - if pool: - args += ["-r", pool] - rc, out, _ = run(args) - if rc != 0: - return [] - rows = [] - for line in out.splitlines(): - parts = line.split("\t") - if len(parts) >= 4: - rows.append({ - "name": parts[0], - "used": parts[1], - "avail": parts[2], - "mountpoint": parts[3], - }) - return rows - - -def list_snapshots(pool: str | None = None) -> list[dict]: - args = ["zfs", "list", "-t", "snapshot", "-H", "-o", - "name,used,refer,creation"] - if pool: - args += ["-r", pool] - rc, out, _ = run(args) - if rc != 0: - return [] - rows = [] - for line in out.splitlines(): - parts = line.split("\t") - if len(parts) >= 4: - rows.append({ - "name": parts[0], - "used": parts[1], - "refer": parts[2], - "creation": parts[3], - }) - return rows - - -def create_snapshot(dataset: str, name: str) -> tuple[bool, str]: - full = f"{dataset}@{name}" - rc, out, err = run(["zfs", "snapshot", full]) - return rc == 0, err or out or f"Created {full}" - - -def destroy_snapshot(snap: str) -> tuple[bool, str]: - rc, out, err = run(["zfs", "destroy", snap]) - return rc == 0, err or out or f"Destroyed {snap}" - - -def rollback_snapshot(snap: str) -> tuple[bool, str]: - rc, out, err = run(["zfs", "rollback", "-r", snap]) - return rc == 0, err or out or f"Rolled back to {snap}" - - -def snapshot_now(dataset: str, keep: int = 7) -> tuple[bool, str]: - """Create an auto-* snapshot immediately and prune older ones beyond keep.""" - ts = datetime.now().strftime("%Y%m%d-%H%M%S") - full = f"{dataset}@auto-{ts}" - rc, out, err = run(["zfs", "snapshot", full]) - if rc != 0: - return False, err or out or f"snapshot failed for {full}" - - rc, out, _ = run([ - "zfs", "list", "-H", "-t", "snapshot", "-o", "name", - "-s", "creation", "-r", dataset, - ]) - pruned = 0 - if rc == 0: - auto_snaps = [ - s for s in out.splitlines() - if s.startswith(f"{dataset}@auto-") - ] - excess = len(auto_snaps) - keep - if excess > 0: - for old in auto_snaps[:excess]: - drc, _, _ = run(["zfs", "destroy", old]) - if drc == 0: - pruned += 1 - suffix = f" (pruned {pruned})" if pruned else "" - return True, f"Created {full}{suffix}" - - -# ----------------------- Cron helpers ----------------------- -# All TUI-managed cron entries live in /etc/cron.d/pve-tui (single owner file). -# Root's crontab is never modified by this app, except by the one-shot -# migrate_legacy_crontab() which moves any pre-existing pve-tui entries into -# the new file. - -CRON_FILE = Path("/etc/cron.d/pve-tui") -SNAPSHOT_SCRIPT = Path("/usr/local/bin/pve-tui-snapshot.sh") -CRON_HEADER = """# Auto-generated by pve-tui — do not edit manually. -# Manage entries via the TUI Schedules / Scrub tabs. -SHELL=/bin/bash -PATH=/usr/sbin:/usr/bin:/sbin:/bin -MAILTO=\"\" -""" - - -def _read_cron_lines() -> list[str]: - if not CRON_FILE.exists(): - return [] - try: - return CRON_FILE.read_text().splitlines() - except Exception: - return [] - - -def _parse_cron_file() -> tuple[list[dict], list[dict]]: - """Parse CRON_FILE → (snapshot_entries, scrub_entries).""" - snapshots: list[dict] = [] - scrubs: list[dict] = [] - snap_re = re.compile( - r"^(\S+\s+\S+\s+\S+\s+\S+\s+\S+)\s+\S+\s+/usr/local/bin/pve-tui-snapshot\.sh\s+(\S+)\s+(\d+)" - ) - scrub_re = re.compile( - r"^(\S+\s+\S+\s+\S+\s+\S+\s+\S+)\s+\S+\s+/usr/sbin/zpool\s+scrub\s+(\S+)\s*$" - ) - for raw in _read_cron_lines(): - line = raw.strip() - if not line or line.startswith("#"): - continue - first = line.split()[0] - if "=" in first: - continue - m = snap_re.match(line) - if m: - snapshots.append( - {"cron": m.group(1), "dataset": m.group(2), "keep": m.group(3)} - ) - continue - m = scrub_re.match(line) - if m: - scrubs.append({"cron": m.group(1), "pool": m.group(2)}) - return snapshots, scrubs - - -def _write_cron_file(snapshots: list[dict], scrubs: list[dict]) -> bool: - parts = [CRON_HEADER.rstrip(), ""] - if snapshots: - parts.append("# Snapshot schedules (auto-prune keeps last N)") - for e in snapshots: - parts.append( - f"{e['cron']} root {SNAPSHOT_SCRIPT} {e['dataset']} {e['keep']}" - ) - parts.append("") - if scrubs: - parts.append("# Scrub schedules") - for e in scrubs: - parts.append(f"{e['cron']} root /usr/sbin/zpool scrub {e['pool']}") - parts.append("") - try: - CRON_FILE.parent.mkdir(parents=True, exist_ok=True) - CRON_FILE.write_text("\n".join(parts) + "\n") - CRON_FILE.chmod(0o644) - return True - except Exception: - return False - - -def install_snapshot_script() -> None: - script = """#!/bin/bash -# Auto-generated by pve-tui. Creates snapshot and prunes old ones. -DATASET="$1" -KEEP="${2:-7}" -PREFIX="auto" - -if [ -z "$DATASET" ]; then - echo "Usage: $0 [keep_count]" - exit 1 -fi - -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" | wc -l) -if [ "$COUNT" -gt "$KEEP" ]; then - DEL=$((COUNT - KEEP)) - echo "$SNAPS" | head -n "$DEL" | while read S; do - /usr/sbin/zfs destroy "$S" - done -fi -""" - SNAPSHOT_SCRIPT.write_text(script) - SNAPSHOT_SCRIPT.chmod(0o755) - - -def get_schedules() -> list[dict]: - return _parse_cron_file()[0] - - -def set_schedule(dataset: str, cron_expr: str, keep: int) -> bool: - install_snapshot_script() - snaps, scrubs = _parse_cron_file() - snaps = [s for s in snaps if s["dataset"] != dataset] - snaps.append({"cron": cron_expr, "dataset": dataset, "keep": str(keep)}) - return _write_cron_file(snaps, scrubs) - - -def remove_schedule(dataset: str) -> bool: - snaps, scrubs = _parse_cron_file() - snaps = [s for s in snaps if s["dataset"] != dataset] - return _write_cron_file(snaps, scrubs) - - -def migrate_legacy_crontab() -> bool: - """One-time: extract pve-tui blocks from root's crontab into CRON_FILE - and strip them from the crontab. Returns True if a migration ran.""" - if CRON_FILE.exists(): - return False - rc, out, _ = run(["crontab", "-l"]) - if rc != 0 or "PVE-TUI" not in out: - return False - - snaps: list[dict] = [] - scrubs: list[dict] = [] - new_crontab: list[str] = [] - in_snap = False - in_scrub = False - snap_re = re.compile( - r"^(\S+\s+\S+\s+\S+\s+\S+\s+\S+)\s+.*pve-tui-snapshot\.sh\s+(\S+)\s+(\d+)" - ) - scrub_re = re.compile( - r"^(\S+\s+\S+\s+\S+\s+\S+\s+\S+)\s+/usr/sbin/zpool\s+scrub\s+(\S+)" - ) - for line in out.splitlines(): - if "PVE-TUI AUTO SNAPSHOT BEGIN" in line: - in_snap = True - continue - if "PVE-TUI AUTO SNAPSHOT END" in line: - in_snap = False - continue - if "PVE-TUI AUTO SCRUB BEGIN" in line: - in_scrub = True - continue - if "PVE-TUI AUTO SCRUB END" in line: - in_scrub = False - continue - if in_snap: - m = snap_re.match(line) - if m: - snaps.append( - {"cron": m.group(1), "dataset": m.group(2), "keep": m.group(3)} - ) - continue - if in_scrub: - m = scrub_re.match(line) - if m: - scrubs.append({"cron": m.group(1), "pool": m.group(2)}) - continue - new_crontab.append(line) - - install_snapshot_script() - _write_cron_file(snaps, scrubs) - try: - p = subprocess.Popen(["crontab", "-"], stdin=subprocess.PIPE, text=True) - p.communicate("\n".join(new_crontab) + "\n") - except Exception: - pass - return True - - -# ----------------------- LXC helpers ----------------------- - -def list_lxc() -> list[dict]: - rc, out, _ = run(["pct", "list"]) - if rc != 0: - return [] - rows = [] - for line in out.splitlines()[1:]: # skip header - parts = line.split(maxsplit=3) - if len(parts) >= 3: - rows.append({ - "id": parts[0], - "status": parts[1], - "name": parts[-1] if len(parts) > 2 else "", - }) - return rows - - -def get_lxc_mountpoints(ctid: str) -> list[dict]: - rc, out, _ = run(["pct", "config", ctid]) - if rc != 0: - return [] - mps = [] - for line in out.splitlines(): - m = re.match(r"^(mp\d+):\s*(.+)$", line) - if m: - key = m.group(1) - val = m.group(2) - # val like: /host/path,mp=/ct/path,size=8G - parts = val.split(",") - host = parts[0] - target = "" - for p in parts[1:]: - if p.startswith("mp="): - target = p[3:] - mps.append({ - "key": key, - "host": host, - "target": target, - "raw": val, - }) - return mps - - -def add_lxc_mount(ctid: str, host_path: str, ct_path: str) -> tuple[bool, str]: - # Find next free mp slot - existing = get_lxc_mountpoints(ctid) - used = {int(m["key"][2:]) for m in existing} - slot = 0 - while slot in used: - slot += 1 - rc, out, err = run([ - "pct", "set", ctid, f"-mp{slot}", - f"{host_path},mp={ct_path}" - ]) - return rc == 0, err or out or f"Added mp{slot}" - - -def remove_lxc_mount(ctid: str, key: str) -> tuple[bool, str]: - rc, out, err = run(["pct", "set", ctid, f"-delete", key]) - return rc == 0, err or out or f"Removed {key}" - - -# ----------------------- Replication helpers ----------------------- - -REPLICATION_CONFIG = Path("/etc/pve-tui/replication.json") -REPLICATION_SCRIPT = Path("/usr/local/bin/pve-tui-replicate.sh") - - -def load_replication_targets() -> list[dict]: - if not REPLICATION_CONFIG.exists(): - return [] - try: - return json.loads(REPLICATION_CONFIG.read_text()) - except Exception: - return [] - - -def save_replication_targets(targets: list[dict]) -> bool: - try: - REPLICATION_CONFIG.parent.mkdir(parents=True, exist_ok=True) - REPLICATION_CONFIG.write_text(json.dumps(targets, indent=2)) - return True - except Exception: - return False - - -def list_dataset_snapshots(dataset: str) -> list[str]: - """Snapshot short-names (no `dataset@` prefix) for an exact dataset, oldest first.""" - rc, out, _ = run([ - "zfs", "list", "-H", "-t", "snapshot", "-o", "name", - "-s", "creation", dataset, - ]) - if rc != 0: - return [] - names = [] - for line in out.splitlines(): - if "@" in line: - ds, snap = line.split("@", 1) - if ds == dataset: - names.append(snap) - return names - - -def latest_snapshot(dataset: str) -> str | None: - snaps = list_dataset_snapshots(dataset) - return snaps[-1] if snaps else None - - -def latest_common_snapshot(source: str, target: str) -> str | None: - src = list_dataset_snapshots(source) - tgt = set(list_dataset_snapshots(target)) - for s in reversed(src): - if s in tgt: - return s - return None - - -def dataset_exists(dataset: str) -> bool: - rc, _, _ = run(["zfs", "list", "-H", dataset]) - return rc == 0 - - -def replicate_dataset(source: str, target: str) -> tuple[bool, str]: - """Full or incremental zfs send|recv, automatically picking the right mode.""" - src_snaps = list_dataset_snapshots(source) - if not src_snaps: - return False, f"No snapshots on {source}; create one first." - latest_src = src_snaps[-1] - - if dataset_exists(target): - common = latest_common_snapshot(source, target) - if not common: - return False, ( - f"{target} exists but shares no snapshot with {source}. " - f"Destroy {target} for a fresh full send, or align bases manually." - ) - if common == latest_src: - return True, f"Up-to-date: {source}@{common}" - cmd = ( - f"zfs send -R -I {shlex.quote(source + '@' + common)} " - f"{shlex.quote(source + '@' + latest_src)} | " - f"zfs receive -F {shlex.quote(target)}" - ) - mode = f"incremental {common} → {latest_src}" - else: - cmd = ( - f"zfs send -R {shlex.quote(source + '@' + latest_src)} | " - f"zfs receive {shlex.quote(target)}" - ) - mode = f"full → {latest_src}" - - proc = subprocess.run(["bash", "-c", cmd], capture_output=True, text=True) - if proc.returncode != 0: - return False, (proc.stderr.strip() or proc.stdout.strip() - or "replication failed").splitlines()[-1] - return True, f"Replicated {source} → {target} ({mode})" - - -def install_replication_script() -> None: - """Standalone replication helper for cron / manual use.""" - script = """#!/bin/bash -# Auto-generated by pve-tui. Incrementally replicates ZFS dataset. -# Usage: pve-tui-replicate.sh -set -e -SOURCE="$1" -TARGET="$2" -if [ -z "$SOURCE" ] || [ -z "$TARGET" ]; then - echo "Usage: $0 " - exit 1 -fi - -LATEST=$(/usr/sbin/zfs list -H -t snapshot -o name -s creation "$SOURCE" | tail -n1 | awk -F@ '{print $2}') -if [ -z "$LATEST" ]; then - echo "No snapshots on $SOURCE"; exit 1 -fi - -if /usr/sbin/zfs list -H "$TARGET" >/dev/null 2>&1; then - # Find latest snapshot name that exists on both source and target. - 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 - if [ -z "$COMMON" ]; then - echo "No common snapshot between $SOURCE and $TARGET; aborting." >&2; exit 2 - fi - 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 -""" - REPLICATION_SCRIPT.write_text(script) - REPLICATION_SCRIPT.chmod(0o755) - - -# ----------------------- Scrub helpers ----------------------- - -def scrub_status(pool: str) -> dict: - rc, out, _ = run(["zpool", "status", pool]) - info = {"pool": pool, "state": "?", "scan": "", "errors": ""} - if rc != 0: - return info - for line in out.splitlines(): - s = line.strip() - if s.startswith("state:"): - info["state"] = s.split(":", 1)[1].strip() - elif s.startswith("scan:"): - info["scan"] = s.split(":", 1)[1].strip() - elif s.startswith("errors:"): - info["errors"] = s.split(":", 1)[1].strip() - return info - - -def scrub_start(pool: str) -> tuple[bool, str]: - rc, out, err = run(["zpool", "scrub", pool]) - return rc == 0, err or out or f"Started scrub on {pool}" - - -def scrub_stop(pool: str) -> tuple[bool, str]: - rc, out, err = run(["zpool", "scrub", "-s", pool]) - return rc == 0, err or out or f"Stopped scrub on {pool}" - - -def get_scrub_schedules() -> dict[str, str]: - """pool -> cron expression for TUI-managed scrub entries.""" - return {s["pool"]: s["cron"] for s in _parse_cron_file()[1]} - - -def set_scrub_schedule(pool: str, cron_expr: str) -> bool: - snaps, scrubs = _parse_cron_file() - scrubs = [s for s in scrubs if s["pool"] != pool] - scrubs.append({"cron": cron_expr, "pool": pool}) - return _write_cron_file(snaps, scrubs) - - -def remove_scrub_schedule(pool: str) -> bool: - snaps, scrubs = _parse_cron_file() - scrubs = [s for s in scrubs if s["pool"] != pool] - return _write_cron_file(snaps, scrubs) - - -# ----------------------- Modals ----------------------- - -class ConfirmModal(ModalScreen[bool]): - def __init__(self, message: str): - super().__init__() - self.message = message - - def compose(self) -> ComposeResult: - with Container(classes="modal-box"): - yield Label(self.message, classes="modal-title") - with Horizontal(classes="modal-buttons"): - yield Button("Yes", variant="error", id="yes") - yield Button("No", variant="primary", id="no") - - def on_button_pressed(self, event: Button.Pressed) -> None: - self.dismiss(event.button.id == "yes") - - -class CreateSnapshotModal(ModalScreen[tuple[str, str] | None]): - def __init__(self, datasets: list[str]): - super().__init__() - self.datasets = datasets - - def compose(self) -> ComposeResult: - with Container(classes="modal-box"): - yield Label("Create Snapshot", classes="modal-title") - yield Label("Dataset:") - yield Select( - [(d, d) for d in self.datasets], - id="dataset", - value=self.datasets[0] if self.datasets else None, - ) - yield Label("Snapshot name:") - default_name = f"manual-{datetime.now().strftime('%Y%m%d-%H%M%S')}" - yield Input(value=default_name, id="snap-name") - with Horizontal(classes="modal-buttons"): - yield Button("Create", variant="success", id="create") - yield Button("Cancel", id="cancel") - - def on_button_pressed(self, event: Button.Pressed) -> None: - if event.button.id == "create": - dataset = self.query_one("#dataset", Select).value - name = self.query_one("#snap-name", Input).value.strip() - if dataset and name: - self.dismiss((str(dataset), name)) - else: - self.dismiss(None) - else: - self.dismiss(None) - - -class ScheduleModal(ModalScreen[tuple[str, str, int] | None]): - PRESETS = [ - ("Hourly", "0 * * * *"), - ("Every 6 hours", "0 */6 * * *"), - ("Daily at midnight", "0 0 * * *"), - ("Daily at 3am", "0 3 * * *"), - ("Weekly (Sunday 2am)", "0 2 * * 0"), - ("Custom...", "custom"), - ] - - def __init__(self, datasets: list[str]): - super().__init__() - self.datasets = datasets - - def compose(self) -> ComposeResult: - with Container(classes="modal-box"): - yield Label("Schedule Auto-Snapshot", classes="modal-title") - yield Label("Dataset:") - yield Select( - [(d, d) for d in self.datasets], - id="dataset", - value=self.datasets[0] if self.datasets else None, - ) - yield Label("Frequency:") - yield Select( - [(name, val) for name, val in self.PRESETS], - id="preset", - value="0 3 * * *", - ) - yield Label("Custom cron (if selected above):") - yield Input(placeholder="e.g. 0 */4 * * *", id="custom-cron") - yield Label("Keep last N snapshots:") - yield Input(value="7", id="keep") - with Horizontal(classes="modal-buttons"): - yield Button("Save", variant="success", id="save") - yield Button("Cancel", id="cancel") - - def on_button_pressed(self, event: Button.Pressed) -> None: - if event.button.id == "save": - dataset = self.query_one("#dataset", Select).value - preset = self.query_one("#preset", Select).value - custom = self.query_one("#custom-cron", Input).value.strip() - keep_str = self.query_one("#keep", Input).value.strip() - - cron_expr = custom if preset == "custom" else preset - try: - keep = int(keep_str) - except ValueError: - keep = 7 - - if dataset and cron_expr: - self.dismiss((str(dataset), str(cron_expr), keep)) - else: - self.dismiss(None) - else: - self.dismiss(None) - - -class AddReplicationModal(ModalScreen[tuple[str, str] | None]): - def __init__(self, sources: list[str]): - super().__init__() - self.sources = sources - - def compose(self) -> ComposeResult: - with Container(classes="modal-box"): - yield Label("Add Replication Target", classes="modal-title") - yield Label("Source dataset:") - yield Select( - [(d, d) for d in self.sources], - id="src", - value=self.sources[0] if self.sources else None, - ) - yield Label("Target dataset (must be on imported pool):") - yield Input(placeholder="e.g. backup_ext/RAID1_1TB", id="tgt") - with Horizontal(classes="modal-buttons"): - yield Button("Add", variant="success", id="add") - yield Button("Cancel", id="cancel") - - def on_button_pressed(self, event: Button.Pressed) -> None: - if event.button.id == "add": - src = self.query_one("#src", Select).value - tgt = self.query_one("#tgt", Input).value.strip() - if src and tgt: - self.dismiss((str(src), tgt)) - return - self.dismiss(None) - - -class ScrubScheduleModal(ModalScreen[str | None]): - PRESETS = [ - ("Monthly (1st @ 3am)", "0 3 1 * *"), - ("Bi-weekly (1st & 15th @ 3am)", "0 3 1,15 * *"), - ("Weekly (Sunday 3am)", "0 3 * * 0"), - ("Custom...", "custom"), - ] - - def compose(self) -> ComposeResult: - with Container(classes="modal-box"): - yield Label("Schedule Scrub", classes="modal-title") - yield Label("Frequency:") - yield Select( - [(name, val) for name, val in self.PRESETS], - id="preset", - value="0 3 1 * *", - ) - yield Label("Custom cron (if selected above):") - yield Input(placeholder="e.g. 0 3 1 * *", id="custom-cron") - with Horizontal(classes="modal-buttons"): - yield Button("Save", variant="success", id="save") - yield Button("Cancel", id="cancel") - - def on_button_pressed(self, event: Button.Pressed) -> None: - if event.button.id == "save": - preset = self.query_one("#preset", Select).value - custom = self.query_one("#custom-cron", Input).value.strip() - cron_expr = custom if preset == "custom" else preset - if cron_expr: - self.dismiss(str(cron_expr)) - return - self.dismiss(None) - - -class AddMountModal(ModalScreen[tuple[str, str, str] | None]): - def __init__(self, ctids: list[str]): - super().__init__() - self.ctids = ctids - - def compose(self) -> ComposeResult: - with Container(classes="modal-box"): - yield Label("Add LXC Mountpoint", classes="modal-title") - yield Label("Container:") - yield Select( - [(c, c) for c in self.ctids], - id="ctid", - value=self.ctids[0] if self.ctids else None, - ) - yield Label("Host path (on Proxmox):") - yield Input(placeholder="/RAID1_1TB/MyData", id="host") - yield Label("Container path (inside LXC):") - yield Input(placeholder="/RAID1_1TB/MyData", id="target") - with Horizontal(classes="modal-buttons"): - yield Button("Add", variant="success", id="add") - yield Button("Cancel", id="cancel") - - def on_button_pressed(self, event: Button.Pressed) -> None: - if event.button.id == "add": - ctid = self.query_one("#ctid", Select).value - host = self.query_one("#host", Input).value.strip() - target = self.query_one("#target", Input).value.strip() - if ctid and host and target: - self.dismiss((str(ctid), host, target)) - else: - self.dismiss(None) - else: - self.dismiss(None) - - -# ----------------------- Main App ----------------------- - -class PVETui(App): - CSS = """ - Screen { - background: $surface; - } - - TabbedContent { - height: 1fr; - } - - DataTable { - height: 1fr; - } - - .toolbar { - height: auto; - padding: 1; - background: $panel; - } - - .toolbar Button { - margin-right: 1; - } - - .status { - dock: bottom; - height: 1; - background: $primary; - color: $text; - padding: 0 1; - } - - .modal-box { - align: center middle; - background: $panel; - border: thick $primary; - padding: 1 2; - width: 60; - height: auto; - } - - .modal-title { - text-align: center; - text-style: bold; - margin-bottom: 1; - } - - .modal-buttons { - align: center middle; - height: auto; - margin-top: 1; - } - - .modal-buttons Button { - margin: 0 1; - } - - Label { - margin-top: 1; - } - """ - - BINDINGS = [ - Binding("q", "quit", "Quit"), - Binding("r", "refresh", "Refresh"), - Binding("n", "new_snapshot", "New Snapshot"), - Binding("d", "delete_snapshot", "Delete"), - Binding("s", "schedule", "Schedule"), - ] - - TITLE = "PVE Storage Manager" - SUB_TITLE = "ZFS Snapshots + LXC Mountpoints" - - def compose(self) -> ComposeResult: - yield Header() - with TabbedContent(initial="snapshots"): - with TabPane("Snapshots", id="snapshots"): - with Horizontal(classes="toolbar"): - yield Button("New (n)", id="btn-new", variant="success") - yield Button("Delete (d)", id="btn-del", variant="error") - yield Button("Rollback", id="btn-rollback", variant="warning") - yield Button("Refresh (r)", id="btn-refresh") - yield DataTable(id="snap-table", cursor_type="row") - - with TabPane("Schedules", id="schedules"): - with Horizontal(classes="toolbar"): - yield Button("Add Schedule (s)", id="btn-sched-add", variant="success") - yield Button("Snapshot Now", id="btn-sched-now", variant="primary") - yield Button("Remove", id="btn-sched-del", variant="error") - yield Button("Refresh", id="btn-sched-refresh") - yield DataTable(id="sched-table", cursor_type="row") - - with TabPane("Datasets", id="datasets"): - with Horizontal(classes="toolbar"): - yield Button("Refresh", id="btn-ds-refresh") - yield DataTable(id="ds-table", cursor_type="row") - - with TabPane("LXC Mounts", id="mounts"): - with Horizontal(classes="toolbar"): - yield Button("Add Mount", id="btn-mp-add", variant="success") - yield Button("Remove Mount", id="btn-mp-del", variant="error") - yield Button("Refresh", id="btn-mp-refresh") - yield DataTable(id="mp-table", cursor_type="row") - - with TabPane("Replication", id="replication"): - with Horizontal(classes="toolbar"): - yield Button("Add Target", id="btn-rep-add", variant="success") - yield Button("Replicate Now", id="btn-rep-run", variant="primary") - yield Button("Remove", id="btn-rep-del", variant="error") - yield Button("Refresh", id="btn-rep-refresh") - yield DataTable(id="rep-table", cursor_type="row") - - with TabPane("Scrub", id="scrub"): - with Horizontal(classes="toolbar"): - yield Button("Start Scrub", id="btn-scrub-start", variant="success") - yield Button("Stop Scrub", id="btn-scrub-stop", variant="warning") - yield Button("Schedule", id="btn-scrub-sched") - yield Button("Unschedule", id="btn-scrub-unsched", variant="error") - yield Button("Refresh", id="btn-scrub-refresh") - yield DataTable(id="scrub-table", cursor_type="row") - - yield Static("Ready", id="status", classes="status") - yield Footer() - - def on_mount(self) -> None: - if migrate_legacy_crontab(): - self.set_status("Migrated legacy crontab entries → /etc/cron.d/pve-tui") - - # Snapshot table - t = self.query_one("#snap-table", DataTable) - t.add_columns("Snapshot", "Used", "Refer", "Created") - - # Schedule table - t = self.query_one("#sched-table", DataTable) - t.add_columns("Dataset", "Cron", "Keep") - - # Dataset table - t = self.query_one("#ds-table", DataTable) - t.add_columns("Name", "Used", "Available", "Mountpoint") - - # Mountpoint table - t = self.query_one("#mp-table", DataTable) - t.add_columns("CTID", "Slot", "Host Path", "Container Path") - - # Replication table - t = self.query_one("#rep-table", DataTable) - t.add_columns("Source", "Target", "Common", "Latest Src", "Status") - - # Scrub table - t = self.query_one("#scrub-table", DataTable) - t.add_columns("Pool", "State", "Last/Current Scan", "Errors", "Schedule") - - self.refresh_all() - - def set_status(self, msg: str) -> None: - self.query_one("#status", Static).update(msg) - - def refresh_all(self) -> None: - self.refresh_snapshots() - self.refresh_schedules() - self.refresh_datasets() - self.refresh_mounts() - self.refresh_replication() - self.refresh_scrub() - self.set_status(f"Refreshed at {datetime.now().strftime('%H:%M:%S')}") - - def refresh_snapshots(self) -> None: - t = self.query_one("#snap-table", DataTable) - t.clear() - for snap in list_snapshots(): - t.add_row(snap["name"], snap["used"], snap["refer"], snap["creation"]) - - def refresh_schedules(self) -> None: - t = self.query_one("#sched-table", DataTable) - t.clear() - for s in get_schedules(): - t.add_row(s["dataset"], s["cron"], s["keep"]) - - def refresh_datasets(self) -> None: - t = self.query_one("#ds-table", DataTable) - t.clear() - for d in list_datasets(): - t.add_row(d["name"], d["used"], d["avail"], d["mountpoint"]) - - def refresh_mounts(self) -> None: - t = self.query_one("#mp-table", DataTable) - t.clear() - for lxc in list_lxc(): - for mp in get_lxc_mountpoints(lxc["id"]): - t.add_row(lxc["id"], mp["key"], mp["host"], mp["target"]) - - def refresh_replication(self) -> None: - t = self.query_one("#rep-table", DataTable) - t.clear() - for tgt in load_replication_targets(): - src = tgt["source"] - dst = tgt["target"] - common = latest_common_snapshot(src, dst) or "-" - latest = latest_snapshot(src) or "-" - if not dataset_exists(dst): - status = "target missing (full send needed)" - elif common == latest: - status = "up-to-date" - elif common == "-": - status = "no common snap (mismatch)" - else: - status = "incremental pending" - t.add_row(src, dst, common, latest, status) - - def refresh_scrub(self) -> None: - t = self.query_one("#scrub-table", DataTable) - t.clear() - sched = get_scrub_schedules() - for pool in list_pools(): - info = scrub_status(pool) - t.add_row( - pool, - info["state"], - info["scan"][:60] if info["scan"] else "-", - info["errors"] or "-", - sched.get(pool, "-"), - ) - - # ------------- Actions ------------- - - def action_refresh(self) -> None: - self.refresh_all() - - def action_new_snapshot(self) -> None: - self._open_create_snapshot() - - def action_delete_snapshot(self) -> None: - self._delete_selected_snapshot() - - def action_schedule(self) -> None: - self._open_schedule() - - def _get_datasets(self) -> list[str]: - return [d["name"] for d in list_datasets()] - - def _open_create_snapshot(self) -> None: - datasets = self._get_datasets() - if not datasets: - self.set_status("No datasets found") - return - - def cb(result): - if result: - dataset, name = result - ok, msg = create_snapshot(dataset, name) - self.set_status(msg) - self.refresh_snapshots() - - self.push_screen(CreateSnapshotModal(datasets), cb) - - def _delete_selected_snapshot(self) -> None: - t = self.query_one("#snap-table", DataTable) - if t.cursor_row is None: - return - try: - row = t.get_row_at(t.cursor_row) - snap_name = row[0] - except Exception: - return - - def cb(confirmed): - if confirmed: - ok, msg = destroy_snapshot(snap_name) - self.set_status(msg) - self.refresh_snapshots() - - self.push_screen( - ConfirmModal(f"Destroy snapshot:\n{snap_name}?"), cb - ) - - def _rollback_selected_snapshot(self) -> None: - t = self.query_one("#snap-table", DataTable) - if t.cursor_row is None: - return - try: - row = t.get_row_at(t.cursor_row) - snap_name = row[0] - except Exception: - return - - def cb(confirmed): - if confirmed: - ok, msg = rollback_snapshot(snap_name) - self.set_status(msg) - self.refresh_all() - - self.push_screen( - ConfirmModal( - f"ROLLBACK to {snap_name}?\n" - "This will DESTROY all newer snapshots\n" - "and changes since then!" - ), cb - ) - - def _open_schedule(self) -> None: - datasets = self._get_datasets() - if not datasets: - self.set_status("No datasets found") - return - - def cb(result): - if result: - dataset, cron_expr, keep = result - ok = set_schedule(dataset, cron_expr, keep) - self.set_status( - f"Schedule saved for {dataset}" if ok else "Failed to save schedule" - ) - self.refresh_schedules() - - self.push_screen(ScheduleModal(datasets), cb) - - def _snapshot_now_selected(self) -> None: - t = self.query_one("#sched-table", DataTable) - if t.cursor_row is None: - return - try: - row = t.get_row_at(t.cursor_row) - dataset = str(row[0]) - keep = int(str(row[2])) - except Exception: - self.set_status("Select a schedule row first") - return - ok, msg = snapshot_now(dataset, keep) - self.set_status(msg) - self.refresh_snapshots() - - def _remove_selected_schedule(self) -> None: - t = self.query_one("#sched-table", DataTable) - if t.cursor_row is None: - return - try: - row = t.get_row_at(t.cursor_row) - dataset = row[0] - except Exception: - return - - def cb(confirmed): - if confirmed: - ok = remove_schedule(dataset) - self.set_status( - f"Removed schedule for {dataset}" if ok else "Failed" - ) - self.refresh_schedules() - - self.push_screen( - ConfirmModal(f"Remove schedule for:\n{dataset}?"), cb - ) - - def _open_add_mount(self) -> None: - ctids = [c["id"] for c in list_lxc()] - if not ctids: - self.set_status("No LXC containers found") - return - - def cb(result): - if result: - ctid, host, target = result - ok, msg = add_lxc_mount(ctid, host, target) - self.set_status(msg) - self.refresh_mounts() - - self.push_screen(AddMountModal(ctids), cb) - - def _open_add_replication(self) -> None: - sources = self._get_datasets() - if not sources: - self.set_status("No datasets to replicate") - return - - def cb(result): - if result: - src, tgt = result - targets = load_replication_targets() - for t in targets: - if t["source"] == src and t["target"] == tgt: - self.set_status("Target already configured") - return - targets.append({"source": src, "target": tgt}) - if save_replication_targets(targets): - install_replication_script() - self.set_status(f"Added: {src} → {tgt}") - self.refresh_replication() - else: - self.set_status("Failed to save replication config") - - self.push_screen(AddReplicationModal(sources), cb) - - def _remove_selected_replication(self) -> None: - t = self.query_one("#rep-table", DataTable) - if t.cursor_row is None: - return - try: - row = t.get_row_at(t.cursor_row) - src, tgt = str(row[0]), str(row[1]) - except Exception: - return - - def cb(confirmed): - if confirmed: - targets = [ - x for x in load_replication_targets() - if not (x["source"] == src and x["target"] == tgt) - ] - if save_replication_targets(targets): - self.set_status(f"Removed: {src} → {tgt}") - self.refresh_replication() - - self.push_screen( - ConfirmModal( - f"Remove replication target?\n{src} → {tgt}\n" - "(This does not destroy the target dataset.)" - ), - cb, - ) - - @work(thread=True, exclusive=True, group="replicate") - def _do_replicate(self, source: str, target: str) -> None: - ok, msg = replicate_dataset(source, target) - self.app.call_from_thread(self.set_status, msg) - self.app.call_from_thread(self.refresh_replication) - self.app.call_from_thread(self.refresh_snapshots) - - def _replicate_selected(self) -> None: - t = self.query_one("#rep-table", DataTable) - if t.cursor_row is None: - self.set_status("Select a replication row first") - return - try: - row = t.get_row_at(t.cursor_row) - src, tgt = str(row[0]), str(row[1]) - except Exception: - return - self.set_status(f"Replicating {src} → {tgt} ... (this may take a while)") - self._do_replicate(src, tgt) - - def _scrub_action(self, action: str) -> None: - t = self.query_one("#scrub-table", DataTable) - if t.cursor_row is None: - self.set_status("Select a pool row first") - return - try: - row = t.get_row_at(t.cursor_row) - pool = str(row[0]) - except Exception: - return - if action == "start": - ok, msg = scrub_start(pool) - else: - ok, msg = scrub_stop(pool) - self.set_status(msg) - self.refresh_scrub() - - def _open_scrub_schedule(self) -> None: - t = self.query_one("#scrub-table", DataTable) - if t.cursor_row is None: - self.set_status("Select a pool row first") - return - try: - row = t.get_row_at(t.cursor_row) - pool = str(row[0]) - except Exception: - return - - def cb(cron_expr): - if cron_expr: - ok = set_scrub_schedule(pool, cron_expr) - self.set_status( - f"Scrub scheduled for {pool}" if ok else "Failed to save" - ) - self.refresh_scrub() - - self.push_screen(ScrubScheduleModal(), cb) - - def _unschedule_scrub(self) -> None: - t = self.query_one("#scrub-table", DataTable) - if t.cursor_row is None: - return - try: - row = t.get_row_at(t.cursor_row) - pool = str(row[0]) - except Exception: - return - - def cb(confirmed): - if confirmed: - ok = remove_scrub_schedule(pool) - self.set_status( - f"Unscheduled scrub for {pool}" if ok else "Failed" - ) - self.refresh_scrub() - - self.push_screen(ConfirmModal(f"Remove scrub schedule for {pool}?"), cb) - - def _remove_selected_mount(self) -> None: - t = self.query_one("#mp-table", DataTable) - if t.cursor_row is None: - return - try: - row = t.get_row_at(t.cursor_row) - ctid = row[0] - slot = row[1] - except Exception: - return - - def cb(confirmed): - if confirmed: - ok, msg = remove_lxc_mount(ctid, slot) - self.set_status(msg) - self.refresh_mounts() - - self.push_screen( - ConfirmModal(f"Remove {slot} from LXC {ctid}?\n(CT must be stopped for some changes)"), - cb - ) - - # ------------- Button handlers ------------- - - def on_button_pressed(self, event: Button.Pressed) -> None: - bid = event.button.id - if bid == "btn-new": - self._open_create_snapshot() - elif bid == "btn-del": - self._delete_selected_snapshot() - elif bid == "btn-rollback": - self._rollback_selected_snapshot() - elif bid == "btn-refresh": - self.refresh_snapshots() - elif bid == "btn-sched-add": - self._open_schedule() - elif bid == "btn-sched-now": - self._snapshot_now_selected() - elif bid == "btn-sched-del": - self._remove_selected_schedule() - elif bid == "btn-sched-refresh": - self.refresh_schedules() - elif bid == "btn-ds-refresh": - self.refresh_datasets() - elif bid == "btn-mp-add": - self._open_add_mount() - elif bid == "btn-mp-del": - self._remove_selected_mount() - elif bid == "btn-mp-refresh": - self.refresh_mounts() - elif bid == "btn-rep-add": - self._open_add_replication() - elif bid == "btn-rep-run": - self._replicate_selected() - elif bid == "btn-rep-del": - self._remove_selected_replication() - elif bid == "btn-rep-refresh": - self.refresh_replication() - elif bid == "btn-scrub-start": - self._scrub_action("start") - elif bid == "btn-scrub-stop": - self._scrub_action("stop") - elif bid == "btn-scrub-sched": - self._open_scrub_schedule() - elif bid == "btn-scrub-unsched": - self._unschedule_scrub() - elif bid == "btn-scrub-refresh": - self.refresh_scrub() - - -if __name__ == "__main__": - PVETui().run() diff --git a/zfs-snapshot.sh b/zfs-snapshot.sh new file mode 100755 index 0000000..c83cada --- /dev/null +++ b/zfs-snapshot.sh @@ -0,0 +1,695 @@ +#!/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}"; } + +# Show arbitrary text in a scrollable box. +show() { + local title="$1" body="$2" + whiptail --title "$title" --scrolltext --msgbox "${body:-(no output)}" 24 90 +} + +# 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: nameusedrefercreation +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 +# --------------------------------------------------------------------------- +view_snapshots() { + local body + body="$(list_snapshots | awk -F'\t' \ + 'BEGIN{printf "%-48s %8s %8s %s\n","SNAPSHOT","USED","REFER","CREATED"} + {printf "%-48s %8s %8s %s\n",$1,$2,$3,$4}')" + [[ -z "$body" ]] && body="(no snapshots found)" + show "ZFS Snapshots" "$body" +} + +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: datasetcronkeep +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: poolcron +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: [keep] +DATASET="$1"; KEEP="${2:-7}"; PREFIX="auto" +[ -z "$DATASET" ] && { echo "Usage: $0 [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 +} + +view_schedules() { + local body + body="$(parse_snapshot_schedules | awk -F'\t' \ + 'BEGIN{printf "%-40s %-16s %s\n","DATASET","CRON","KEEP"} + {printf "%-40s %-16s %s\n",$1,$2,$3}')" + [[ -z "$body" ]] && body="(no snapshot schedules configured)" + show "Snapshot Schedules" "$body" +} + +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 +# --------------------------------------------------------------------------- +view_datasets() { + local body + body="$(list_datasets | awk -F'\t' \ + 'BEGIN{printf "%-40s %8s %8s %s\n","NAME","USED","AVAIL","MOUNTPOINT"} + {printf "%-40s %8s %8s %s\n",$1,$2,$3,$4}')" + [[ -z "$body" ]] && body="(no datasets found — is 'zfs list' working as root?)" + show "ZFS Datasets" "$body" +} + +# --------------------------------------------------------------------------- +# 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: slothostpathctpath +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 + }' +} + +view_mounts() { + local body="" ctid line + while IFS= read -r ctid; do + [[ -z "$ctid" ]] && continue + while IFS=$'\t' read -r slot host target; do + [[ -z "$slot" ]] && continue + body+="$(printf 'CT %-6s %-6s %-30s -> %s' "$ctid" "$slot" "$host" "$target")"$'\n' + done < <(lxc_mountpoints "$ctid") + done < <(list_lxc_ids) + [[ -z "$body" ]] && body="(no LXC mountpoints found)" + show "LXC Mountpoints" "$body" +} + +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 +set -e +SOURCE="$1"; TARGET="$2" +[ -z "$SOURCE" ] || [ -z "$TARGET" ] && { echo "Usage: $0 "; 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" +} + +view_replication() { + local body="" src tgt common latest status + while IFS=$'\t' read -r src tgt; do + [[ -z "$src" ]] && continue + 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 + body+="$(printf '%-30s -> %-30s [%s]' "$src" "$tgt" "$status")"$'\n' + done < <(load_replication) + [[ -z "$body" ]] && body="(no replication targets configured)" + show "Replication Targets" "$body" +} + +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 +# --------------------------------------------------------------------------- +view_scrub() { + local body="" pool sched line + while IFS= read -r pool; do + [[ -z "$pool" ]] && continue + sched="$(parse_scrub_schedules | awk -F'\t' -v p="$pool" '$1==p {print $2; exit}')" + body+="$(printf '===== %s (schedule: %s) =====' "$pool" "${sched:-none}")"$'\n' + body+="$(zpool status "$pool" 2>&1 | grep -E '^[[:space:]]*(state|scan|errors):' )"$'\n\n' + done < <(list_pools) + [[ -z "$body" ]] && body="(no pools found)" + show "Pool Scrub Status" "$body" +} + +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