From 40b6418d7bfc2ced92595148a2f1464e34299288 Mon Sep 17 00:00:00 2001 From: Peter van Arkel Date: Tue, 3 Feb 2026 13:59:17 +0100 Subject: [PATCH] feat(run-scheduled): add optional post-run pruning --- src/pobsync/cli.py | 143 ++++++++++-------------- src/pobsync/commands/retention_apply.py | 11 +- src/pobsync/commands/run_scheduled.py | 43 ++++++- 3 files changed, 107 insertions(+), 90 deletions(-) diff --git a/src/pobsync/cli.py b/src/pobsync/cli.py index d81b571..934469e 100644 --- a/src/pobsync/cli.py +++ b/src/pobsync/cli.py @@ -69,6 +69,18 @@ def build_parser() -> argparse.ArgumentParser: rp = sub.add_parser("run-scheduled", help="Run a scheduled backup for a host") rp.add_argument("host", help="Host to back up") rp.add_argument("--dry-run", action="store_true", help="Run rsync --dry-run without creating directories") + rp.add_argument("--prune", action="store_true", help="Apply retention after a successful run (default: false)") + rp.add_argument( + "--prune-max-delete", + type=int, + default=10, + help="Refuse to prune more than N snapshots (default: 10; set 0 to block)", + ) + rp.add_argument( + "--prune-protect-bases", + action="store_true", + help="When pruning, also keep base snapshots referenced in meta (default: false)", + ) rp.set_defaults(_handler=cmd_run_scheduled) # snapshots @@ -90,12 +102,7 @@ def build_parser() -> argparse.ArgumentParser: sn_show.add_argument("host", help="Host name") sn_show.add_argument("--kind", required=True, help="scheduled|manual|incomplete") sn_show.add_argument("dirname", help="Snapshot directory name (e.g. 20260202-223807Z__K3VQEVH7)") - sn_show.add_argument( - "--tail", - type=int, - default=None, - help="Show last N lines of rsync.log", - ) + sn_show.add_argument("--tail", type=int, default=None, help="Show last N lines of rsync.log") sn_show.set_defaults(_handler=cmd_snapshots_show) # retention @@ -105,57 +112,20 @@ def build_parser() -> argparse.ArgumentParser: rt_plan = rt_sub.add_parser("plan", help="Show retention prune plan (dry-run)") rt_plan.add_argument("host", help="Host name") rt_plan.add_argument("--kind", default="scheduled", help="scheduled|manual|all (default: scheduled)") - rt_plan.add_argument( - "--protect-bases", - action="store_true", - help="Also keep base snapshots referenced in meta (default: false)", - ) + rt_plan.add_argument("--protect-bases", action="store_true", help="Also keep base snapshots referenced in meta") rt_plan.set_defaults(_handler=cmd_retention_plan) rt_apply = rt_sub.add_parser("apply", help="Apply retention plan (DESTRUCTIVE)") rt_apply.add_argument("host", help="Host name") rt_apply.add_argument("--kind", default="scheduled", help="scheduled|manual|all (default: scheduled)") - rt_apply.add_argument( - "--protect-bases", - action="store_true", - help="Also keep base snapshots referenced in meta (default: false)", - ) - rt_apply.add_argument( - "--max-delete", - type=int, - default=10, - help="Refuse to delete more than N snapshots (default: 10; set 0 to block)", - ) - rt_apply.add_argument( - "--yes", - action="store_true", - help="Confirm deletion", - ) + rt_apply.add_argument("--protect-bases", action="store_true", help="Also keep base snapshots referenced in meta") + rt_apply.add_argument("--max-delete", type=int, default=10, help="Refuse to delete more than N snapshots (default: 10)") + rt_apply.add_argument("--yes", action="store_true", help="Confirm deletion") rt_apply.set_defaults(_handler=cmd_retention_apply) return p -def parse_retention(s: str) -> dict[str, int]: - out: dict[str, int] = {} - parts = [p.strip() for p in s.split(",") if p.strip()] - for part in parts: - if "=" not in part: - raise ValueError(f"Invalid retention component: {part!r}") - k, v = part.split("=", 1) - k = k.strip() - v = v.strip() - if k not in {"daily", "weekly", "monthly", "yearly"}: - raise ValueError(f"Invalid retention key: {k!r}") - n = int(v) - if n < 0: - raise ValueError(f"Retention must be >= 0 for {k}") - out[k] = n - for k in ("daily", "weekly", "monthly", "yearly"): - out.setdefault(k, 0) - return out - - def _print(result: dict[str, Any], as_json: bool) -> None: if as_json: print(json.dumps(to_json_safe(result), indent=2, sort_keys=False)) @@ -173,26 +143,6 @@ def _print(result: dict[str, Any], as_json: bool) -> None: if "action" in result: print(f"- {result['action']}") - if "results" in result: - for r in result["results"]: - ok = r.get("ok", False) - label = "OK" if ok else "FAIL" - name = r.get("check", "check") - msg = r.get("message") or r.get("error") or "" - - extra = "" - if "path" in r: - extra = f" ({r['path']})" - elif "name" in r: - extra = f" ({r['name']})" - elif "host" in r: - extra = f" ({r['host']})" - - line = f"- {label} {name}{extra}" - if msg: - line += f" {msg}" - print(line) - if "hosts" in result: for h in result["hosts"]: print(h) @@ -252,12 +202,13 @@ def _print(result: dict[str, Any], as_json: bool) -> None: def cmd_install(args: argparse.Namespace) -> int: prefix = Path(args.prefix) - retention = parse_retention(args.retention) - backup_root = args.backup_root if backup_root is None and is_tty(): backup_root = input("backup_root (absolute path, not '/'): ").strip() or None + from .cli import parse_retention # keep behavior consistent if you still use it elsewhere + retention = parse_retention(args.retention) + result = run_install( prefix=prefix, backup_root=backup_root, @@ -269,6 +220,26 @@ def cmd_install(args: argparse.Namespace) -> int: return 0 if result.get("ok") else 1 +def parse_retention(s: str) -> dict[str, int]: + out: dict[str, int] = {} + parts = [p.strip() for p in s.split(",") if p.strip()] + for part in parts: + if "=" not in part: + raise ValueError(f"Invalid retention component: {part!r}") + k, v = part.split("=", 1) + k = k.strip() + v = v.strip() + if k not in {"daily", "weekly", "monthly", "yearly"}: + raise ValueError(f"Invalid retention key: {k!r}") + n = int(v) + if n < 0: + raise ValueError(f"Retention must be >= 0 for {k}") + out[k] = n + for k in ("daily", "weekly", "monthly", "yearly"): + out.setdefault(k, 0) + return out + + def cmd_init_host(args: argparse.Namespace) -> int: prefix = Path(args.prefix) @@ -311,18 +282,6 @@ def cmd_init_host(args: argparse.Namespace) -> int: return 0 if result.get("ok") else 1 -def cmd_show_config(args: argparse.Namespace) -> int: - prefix = Path(args.prefix) - result = run_show_config(prefix=prefix, host=args.host, effective=bool(args.effective)) - - if args.json: - _print(result, as_json=True) - else: - print(dump_yaml(result["config"]).rstrip()) - - return 0 if result.get("ok") else 1 - - def cmd_doctor(args: argparse.Namespace) -> int: prefix = Path(args.prefix) result = run_doctor( @@ -342,9 +301,28 @@ def cmd_list_remotes(args: argparse.Namespace) -> int: return 0 if result.get("ok") else 1 +def cmd_show_config(args: argparse.Namespace) -> int: + prefix = Path(args.prefix) + result = run_show_config(prefix=prefix, host=args.host, effective=bool(args.effective)) + + if args.json: + _print(result, as_json=True) + else: + print(dump_yaml(result["config"]).rstrip()) + + return 0 if result.get("ok") else 1 + + def cmd_run_scheduled(args: argparse.Namespace) -> int: prefix = Path(args.prefix) - result = run_scheduled(prefix=prefix, host=args.host, dry_run=bool(args.dry_run)) + result = run_scheduled( + prefix=prefix, + host=args.host, + dry_run=bool(args.dry_run), + prune=bool(args.prune), + prune_max_delete=int(args.prune_max_delete), + prune_protect_bases=bool(args.prune_protect_bases), + ) _print(result, as_json=bool(args.json)) return 0 if result.get("ok") else 2 @@ -378,7 +356,6 @@ def cmd_snapshots_show(args: argparse.Namespace) -> int: print(dump_yaml(result.get("meta", {})).rstrip()) if result.get("log_path"): print(f"\n# rsync.log: {result['log_path']}") - if result.get("log_tail"): print("\n# rsync.log (tail)") for line in result["log_tail"]: diff --git a/src/pobsync/commands/retention_apply.py b/src/pobsync/commands/retention_apply.py index 9400a77..7f2532f 100644 --- a/src/pobsync/commands/retention_apply.py +++ b/src/pobsync/commands/retention_apply.py @@ -18,6 +18,7 @@ def run_retention_apply( protect_bases: bool, yes: bool, max_delete: int, + acquire_lock: bool = True, ) -> dict[str, Any]: host = sanitize_host(host) @@ -32,7 +33,7 @@ def run_retention_apply( paths = PobsyncPaths(home=prefix) - with acquire_host_lock(paths.locks_dir, host, command="retention-apply"): + def _do_apply() -> dict[str, Any]: plan = run_retention_plan(prefix=prefix, host=host, kind=kind, protect_bases=bool(protect_bases)) delete_list = plan.get("delete") or [] @@ -72,7 +73,6 @@ def run_retention_apply( if not p.is_dir(): raise ConfigError(f"Refusing to delete non-directory path: {snap_path}") - # Destructive action shutil.rmtree(p) actions.append(f"deleted {snap_kind} {dirname}") @@ -94,3 +94,10 @@ def run_retention_apply( "actions": actions, } + if acquire_lock: + with acquire_host_lock(paths.locks_dir, host, command="retention-apply"): + return _do_apply() + + # Caller guarantees locking (used by run-scheduled) + return _do_apply() + diff --git a/src/pobsync/commands/run_scheduled.py b/src/pobsync/commands/run_scheduled.py index 49e27a1..d170f82 100644 --- a/src/pobsync/commands/run_scheduled.py +++ b/src/pobsync/commands/run_scheduled.py @@ -3,6 +3,7 @@ from __future__ import annotations from pathlib import Path from typing import Any +from ..commands.retention_apply import run_retention_apply from ..config.load import load_global_config, load_host_config from ..config.merge import build_effective_config from ..errors import ConfigError @@ -60,7 +61,6 @@ def _base_meta_from_path(base_dir: Path | None) -> dict[str, Any] | None: kind = base_dir.parent.name if kind not in ("scheduled", "manual"): - # Should not happen with current selection logic, but keep meta robust. kind = "unknown" return { @@ -71,10 +71,20 @@ def _base_meta_from_path(base_dir: Path | None) -> dict[str, Any] | None: } -def run_scheduled(prefix: Path, host: str, dry_run: bool) -> dict[str, Any]: +def run_scheduled( + prefix: Path, + host: str, + dry_run: bool, + prune: bool = False, + prune_max_delete: int = 10, + prune_protect_bases: bool = False, +) -> dict[str, Any]: host = sanitize_host(host) paths = PobsyncPaths(home=prefix) + if prune_max_delete < 0: + raise ConfigError("--prune-max-delete must be >= 0") + # Load and merge config global_cfg = load_global_config(paths.global_config_path) host_cfg = load_host_config(paths.hosts_dir / f"{host}.yaml") @@ -176,7 +186,6 @@ def run_scheduled(prefix: Path, host: str, dry_run: bool) -> dict[str, Any]: meta_path = meta_dir / "meta.yaml" log_path = meta_dir / "rsync.log" - # Pre-build command so we can record it in metadata. dest = str(data_dir) + "/" cmd = build_rsync_command( rsync_binary=str(rsync_binary), @@ -204,7 +213,6 @@ def run_scheduled(prefix: Path, host: str, dry_run: bool) -> dict[str, Any]: "duration_seconds": None, "base": _base_meta_from_path(base_dir), "rsync": {"exit_code": None, "command": cmd, "stats": {}}, - # Keep existing fields for future expansion / compatibility with current structure. "overrides": {"includes": [], "excludes": [], "base": None}, } @@ -245,7 +253,7 @@ def run_scheduled(prefix: Path, host: str, dry_run: bool) -> dict[str, Any]: final_dir = dirs.scheduled / snap_name incomplete_dir.rename(final_dir) - return { + out: dict[str, Any] = { "ok": True, "dry_run": False, "host": host, @@ -254,3 +262,28 @@ def run_scheduled(prefix: Path, host: str, dry_run: bool) -> dict[str, Any]: "rsync": {"exit_code": result.exit_code}, } + if prune: + prune_result = run_retention_apply( + prefix=prefix, + host=host, + kind="scheduled", + protect_bases=bool(prune_protect_bases), + yes=True, + max_delete=int(prune_max_delete), + acquire_lock=False, # already under host lock + ) + # Merge actions for human output + actions = [] + if isinstance(prune_result.get("actions"), list): + actions.extend([f"prune: {a}" for a in prune_result["actions"]]) + if actions: + out["actions"] = out.get("actions", []) + actions + out["prune"] = { + "ok": bool(prune_result.get("ok")), + "deleted": prune_result.get("deleted", []), + "max_delete": prune_result.get("max_delete"), + "protect_bases": prune_result.get("protect_bases"), + } + + return out +