Move cron management to /etc/cron.d/pve-tui

Stop mutating root's crontab. All snapshot and scrub schedules now live
in a single TUI-owned file at /etc/cron.d/pve-tui, with a one-shot
migrate_legacy_crontab() that extracts pre-existing pve-tui blocks from
the crontab on startup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
root 2026-04-27 23:09:05 +08:00
parent 946caeff90
commit 5eb7572fa1

324
tui.py
View File

@ -137,51 +137,83 @@ def snapshot_now(dataset: str, keep: int = 7) -> tuple[bool, str]:
# ----------------------- Cron helpers ----------------------- # ----------------------- 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_MARKER_BEGIN = "# === PVE-TUI AUTO SNAPSHOT BEGIN ===" CRON_FILE = Path("/etc/cron.d/pve-tui")
CRON_MARKER_END = "# === PVE-TUI AUTO SNAPSHOT END ===" 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_crontab() -> str: def _read_cron_lines() -> list[str]:
rc, out, _ = run(["crontab", "-l"]) if not CRON_FILE.exists():
return out if rc == 0 else "" return []
def write_crontab(content: str) -> bool:
try: try:
p = subprocess.Popen(["crontab", "-"], stdin=subprocess.PIPE, return CRON_FILE.read_text().splitlines()
text=True) except Exception:
p.communicate(content) return []
return p.returncode == 0
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: except Exception:
return False return False
def get_schedules() -> list[dict]: def install_snapshot_script() -> None:
"""Parse our managed cron entries."""
cron = read_crontab()
schedules = []
in_block = False
for line in cron.splitlines():
if CRON_MARKER_BEGIN in line:
in_block = True
continue
if CRON_MARKER_END in line:
in_block = False
continue
if in_block and line.strip() and not line.startswith("#"):
# Format: <cron> /usr/local/bin/pve-tui-snapshot.sh <dataset> <keep>
m = re.match(r"^(\S+\s+\S+\s+\S+\s+\S+\s+\S+)\s+(.*snapshot\.sh)\s+(\S+)\s+(\d+)", line)
if m:
schedules.append({
"cron": m.group(1),
"dataset": m.group(3),
"keep": m.group(4),
})
return schedules
def install_snapshot_script():
script = """#!/bin/bash script = """#!/bin/bash
# Auto-generated by pve-tui. Creates snapshot and prunes old ones. # Auto-generated by pve-tui. Creates snapshot and prunes old ones.
DATASET="$1" DATASET="$1"
@ -196,7 +228,6 @@ fi
TS=$(date +%Y%m%d-%H%M%S) TS=$(date +%Y%m%d-%H%M%S)
/usr/sbin/zfs snapshot "${DATASET}@${PREFIX}-${TS}" /usr/sbin/zfs snapshot "${DATASET}@${PREFIX}-${TS}"
# Prune: keep only last N auto- snapshots
SNAPS=$(/usr/sbin/zfs list -H -t snapshot -o name -s creation "$DATASET" | grep "@${PREFIX}-") SNAPS=$(/usr/sbin/zfs list -H -t snapshot -o name -s creation "$DATASET" | grep "@${PREFIX}-")
COUNT=$(echo "$SNAPS" | wc -l) COUNT=$(echo "$SNAPS" | wc -l)
if [ "$COUNT" -gt "$KEEP" ]; then if [ "$COUNT" -gt "$KEEP" ]; then
@ -206,79 +237,83 @@ if [ "$COUNT" -gt "$KEEP" ]; then
done done
fi fi
""" """
path = Path("/usr/local/bin/pve-tui-snapshot.sh") SNAPSHOT_SCRIPT.write_text(script)
path.write_text(script) SNAPSHOT_SCRIPT.chmod(0o755)
path.chmod(0o755)
def get_schedules() -> list[dict]:
return _parse_cron_file()[0]
def set_schedule(dataset: str, cron_expr: str, keep: int) -> bool: def set_schedule(dataset: str, cron_expr: str, keep: int) -> bool:
"""Upsert a schedule in crontab."""
install_snapshot_script() install_snapshot_script()
cron = read_crontab() snaps, scrubs = _parse_cron_file()
# Remove existing entry for this dataset in our block snaps = [s for s in snaps if s["dataset"] != dataset]
lines = cron.splitlines() snaps.append({"cron": cron_expr, "dataset": dataset, "keep": str(keep)})
new_lines = [] return _write_cron_file(snaps, scrubs)
in_block = False
block_lines = []
block_start_idx = -1
for i, line in enumerate(lines):
if CRON_MARKER_BEGIN in line:
in_block = True
block_start_idx = len(new_lines)
new_lines.append(line)
continue
if CRON_MARKER_END in line:
# flush block_lines, filtered
for bl in block_lines:
if f" {dataset} " not in bl + " ":
new_lines.append(bl)
new_lines.append(line)
block_lines = []
in_block = False
continue
if in_block:
block_lines.append(line)
else:
new_lines.append(line)
# If block didn't exist, add it
if block_start_idx == -1:
new_lines.append(CRON_MARKER_BEGIN)
new_lines.append(CRON_MARKER_END)
# Insert new entry before END marker
final = []
inserted = False
for line in new_lines:
if CRON_MARKER_END in line and not inserted:
final.append(
f"{cron_expr} /usr/local/bin/pve-tui-snapshot.sh {dataset} {keep}"
)
inserted = True
final.append(line)
return write_crontab("\n".join(final) + "\n")
def remove_schedule(dataset: str) -> bool: def remove_schedule(dataset: str) -> bool:
cron = read_crontab() snaps, scrubs = _parse_cron_file()
lines = cron.splitlines() snaps = [s for s in snaps if s["dataset"] != dataset]
new_lines = [] return _write_cron_file(snaps, scrubs)
in_block = False
for line in lines:
if CRON_MARKER_BEGIN in line: def migrate_legacy_crontab() -> bool:
in_block = True """One-time: extract pve-tui blocks from root's crontab into CRON_FILE
new_lines.append(line) 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 continue
if CRON_MARKER_END in line: if "PVE-TUI AUTO SNAPSHOT END" in line:
in_block = False in_snap = False
new_lines.append(line)
continue continue
if in_block and f" {dataset} " in line + " ": if "PVE-TUI AUTO SCRUB BEGIN" in line:
in_scrub = True
continue continue
new_lines.append(line) if "PVE-TUI AUTO SCRUB END" in line:
return write_crontab("\n".join(new_lines) + "\n") 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 ----------------------- # ----------------------- LXC helpers -----------------------
@ -485,10 +520,6 @@ fi
# ----------------------- Scrub helpers ----------------------- # ----------------------- Scrub helpers -----------------------
CRON_SCRUB_BEGIN = "# === PVE-TUI AUTO SCRUB BEGIN ==="
CRON_SCRUB_END = "# === PVE-TUI AUTO SCRUB END ==="
def scrub_status(pool: str) -> dict: def scrub_status(pool: str) -> dict:
rc, out, _ = run(["zpool", "status", pool]) rc, out, _ = run(["zpool", "status", pool])
info = {"pool": pool, "state": "?", "scan": "", "errors": ""} info = {"pool": pool, "state": "?", "scan": "", "errors": ""}
@ -516,83 +547,21 @@ def scrub_stop(pool: str) -> tuple[bool, str]:
def get_scrub_schedules() -> dict[str, str]: def get_scrub_schedules() -> dict[str, str]:
"""Return pool -> cron expression for managed scrub entries.""" """pool -> cron expression for TUI-managed scrub entries."""
cron = read_crontab() return {s["pool"]: s["cron"] for s in _parse_cron_file()[1]}
out: dict[str, str] = {}
in_block = False
for line in cron.splitlines():
if CRON_SCRUB_BEGIN in line:
in_block = True
continue
if CRON_SCRUB_END in line:
in_block = False
continue
if in_block and line.strip() and not line.startswith("#"):
m = re.match(
r"^(\S+\s+\S+\s+\S+\s+\S+\s+\S+)\s+/usr/sbin/zpool\s+scrub\s+(\S+)\s*$",
line,
)
if m:
out[m.group(2)] = m.group(1)
return out
def set_scrub_schedule(pool: str, cron_expr: str) -> bool: def set_scrub_schedule(pool: str, cron_expr: str) -> bool:
cron = read_crontab() snaps, scrubs = _parse_cron_file()
lines = cron.splitlines() scrubs = [s for s in scrubs if s["pool"] != pool]
new_lines: list[str] = [] scrubs.append({"cron": cron_expr, "pool": pool})
in_block = False return _write_cron_file(snaps, scrubs)
block_lines: list[str] = []
block_seen = False
for line in lines:
if CRON_SCRUB_BEGIN in line:
in_block = True
block_seen = True
new_lines.append(line)
continue
if CRON_SCRUB_END in line:
for bl in block_lines:
if bl.strip().endswith(f"zpool scrub {pool}"):
continue
new_lines.append(bl)
new_lines.append(line)
block_lines = []
in_block = False
continue
if in_block:
block_lines.append(line)
else:
new_lines.append(line)
if not block_seen:
new_lines.append(CRON_SCRUB_BEGIN)
new_lines.append(CRON_SCRUB_END)
final: list[str] = []
inserted = False
for line in new_lines:
if CRON_SCRUB_END in line and not inserted:
final.append(f"{cron_expr} /usr/sbin/zpool scrub {pool}")
inserted = True
final.append(line)
return write_crontab("\n".join(final) + "\n")
def remove_scrub_schedule(pool: str) -> bool: def remove_scrub_schedule(pool: str) -> bool:
cron = read_crontab() snaps, scrubs = _parse_cron_file()
new: list[str] = [] scrubs = [s for s in scrubs if s["pool"] != pool]
in_block = False return _write_cron_file(snaps, scrubs)
for line in cron.splitlines():
if CRON_SCRUB_BEGIN in line:
in_block = True
new.append(line)
continue
if CRON_SCRUB_END in line:
in_block = False
new.append(line)
continue
if in_block and line.strip().endswith(f"zpool scrub {pool}"):
continue
new.append(line)
return write_crontab("\n".join(new) + "\n")
# ----------------------- Modals ----------------------- # ----------------------- Modals -----------------------
@ -930,6 +899,9 @@ class PVETui(App):
yield Footer() yield Footer()
def on_mount(self) -> None: def on_mount(self) -> None:
if migrate_legacy_crontab():
self.set_status("Migrated legacy crontab entries → /etc/cron.d/pve-tui")
# Snapshot table # Snapshot table
t = self.query_one("#snap-table", DataTable) t = self.query_one("#snap-table", DataTable)
t.add_columns("Snapshot", "Used", "Refer", "Created") t.add_columns("Snapshot", "Used", "Refer", "Created")