feat(run-scheduled): add optional post-run pruning
This commit is contained in:
@@ -69,6 +69,18 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
rp = sub.add_parser("run-scheduled", help="Run a scheduled backup for a host")
|
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("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("--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)
|
rp.set_defaults(_handler=cmd_run_scheduled)
|
||||||
|
|
||||||
# snapshots
|
# snapshots
|
||||||
@@ -90,12 +102,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
sn_show.add_argument("host", help="Host name")
|
sn_show.add_argument("host", help="Host name")
|
||||||
sn_show.add_argument("--kind", required=True, help="scheduled|manual|incomplete")
|
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("dirname", help="Snapshot directory name (e.g. 20260202-223807Z__K3VQEVH7)")
|
||||||
sn_show.add_argument(
|
sn_show.add_argument("--tail", type=int, default=None, help="Show last N lines of rsync.log")
|
||||||
"--tail",
|
|
||||||
type=int,
|
|
||||||
default=None,
|
|
||||||
help="Show last N lines of rsync.log",
|
|
||||||
)
|
|
||||||
sn_show.set_defaults(_handler=cmd_snapshots_show)
|
sn_show.set_defaults(_handler=cmd_snapshots_show)
|
||||||
|
|
||||||
# retention
|
# 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 = rt_sub.add_parser("plan", help="Show retention prune plan (dry-run)")
|
||||||
rt_plan.add_argument("host", help="Host name")
|
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("--kind", default="scheduled", help="scheduled|manual|all (default: scheduled)")
|
||||||
rt_plan.add_argument(
|
rt_plan.add_argument("--protect-bases", action="store_true", help="Also keep base snapshots referenced in meta")
|
||||||
"--protect-bases",
|
|
||||||
action="store_true",
|
|
||||||
help="Also keep base snapshots referenced in meta (default: false)",
|
|
||||||
)
|
|
||||||
rt_plan.set_defaults(_handler=cmd_retention_plan)
|
rt_plan.set_defaults(_handler=cmd_retention_plan)
|
||||||
|
|
||||||
rt_apply = rt_sub.add_parser("apply", help="Apply retention plan (DESTRUCTIVE)")
|
rt_apply = rt_sub.add_parser("apply", help="Apply retention plan (DESTRUCTIVE)")
|
||||||
rt_apply.add_argument("host", help="Host name")
|
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("--kind", default="scheduled", help="scheduled|manual|all (default: scheduled)")
|
||||||
rt_apply.add_argument(
|
rt_apply.add_argument("--protect-bases", action="store_true", help="Also keep base snapshots referenced in meta")
|
||||||
"--protect-bases",
|
rt_apply.add_argument("--max-delete", type=int, default=10, help="Refuse to delete more than N snapshots (default: 10)")
|
||||||
action="store_true",
|
rt_apply.add_argument("--yes", action="store_true", help="Confirm deletion")
|
||||||
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.set_defaults(_handler=cmd_retention_apply)
|
rt_apply.set_defaults(_handler=cmd_retention_apply)
|
||||||
|
|
||||||
return p
|
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:
|
def _print(result: dict[str, Any], as_json: bool) -> None:
|
||||||
if as_json:
|
if as_json:
|
||||||
print(json.dumps(to_json_safe(result), indent=2, sort_keys=False))
|
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:
|
if "action" in result:
|
||||||
print(f"- {result['action']}")
|
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:
|
if "hosts" in result:
|
||||||
for h in result["hosts"]:
|
for h in result["hosts"]:
|
||||||
print(h)
|
print(h)
|
||||||
@@ -252,12 +202,13 @@ def _print(result: dict[str, Any], as_json: bool) -> None:
|
|||||||
|
|
||||||
def cmd_install(args: argparse.Namespace) -> int:
|
def cmd_install(args: argparse.Namespace) -> int:
|
||||||
prefix = Path(args.prefix)
|
prefix = Path(args.prefix)
|
||||||
retention = parse_retention(args.retention)
|
|
||||||
|
|
||||||
backup_root = args.backup_root
|
backup_root = args.backup_root
|
||||||
if backup_root is None and is_tty():
|
if backup_root is None and is_tty():
|
||||||
backup_root = input("backup_root (absolute path, not '/'): ").strip() or None
|
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(
|
result = run_install(
|
||||||
prefix=prefix,
|
prefix=prefix,
|
||||||
backup_root=backup_root,
|
backup_root=backup_root,
|
||||||
@@ -269,6 +220,26 @@ def cmd_install(args: argparse.Namespace) -> int:
|
|||||||
return 0 if result.get("ok") else 1
|
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:
|
def cmd_init_host(args: argparse.Namespace) -> int:
|
||||||
prefix = Path(args.prefix)
|
prefix = Path(args.prefix)
|
||||||
|
|
||||||
@@ -311,18 +282,6 @@ def cmd_init_host(args: argparse.Namespace) -> int:
|
|||||||
return 0 if result.get("ok") else 1
|
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:
|
def cmd_doctor(args: argparse.Namespace) -> int:
|
||||||
prefix = Path(args.prefix)
|
prefix = Path(args.prefix)
|
||||||
result = run_doctor(
|
result = run_doctor(
|
||||||
@@ -342,9 +301,28 @@ def cmd_list_remotes(args: argparse.Namespace) -> int:
|
|||||||
return 0 if result.get("ok") else 1
|
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:
|
def cmd_run_scheduled(args: argparse.Namespace) -> int:
|
||||||
prefix = Path(args.prefix)
|
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))
|
_print(result, as_json=bool(args.json))
|
||||||
return 0 if result.get("ok") else 2
|
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())
|
print(dump_yaml(result.get("meta", {})).rstrip())
|
||||||
if result.get("log_path"):
|
if result.get("log_path"):
|
||||||
print(f"\n# rsync.log: {result['log_path']}")
|
print(f"\n# rsync.log: {result['log_path']}")
|
||||||
|
|
||||||
if result.get("log_tail"):
|
if result.get("log_tail"):
|
||||||
print("\n# rsync.log (tail)")
|
print("\n# rsync.log (tail)")
|
||||||
for line in result["log_tail"]:
|
for line in result["log_tail"]:
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ def run_retention_apply(
|
|||||||
protect_bases: bool,
|
protect_bases: bool,
|
||||||
yes: bool,
|
yes: bool,
|
||||||
max_delete: int,
|
max_delete: int,
|
||||||
|
acquire_lock: bool = True,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
host = sanitize_host(host)
|
host = sanitize_host(host)
|
||||||
|
|
||||||
@@ -32,7 +33,7 @@ def run_retention_apply(
|
|||||||
|
|
||||||
paths = PobsyncPaths(home=prefix)
|
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))
|
plan = run_retention_plan(prefix=prefix, host=host, kind=kind, protect_bases=bool(protect_bases))
|
||||||
|
|
||||||
delete_list = plan.get("delete") or []
|
delete_list = plan.get("delete") or []
|
||||||
@@ -72,7 +73,6 @@ def run_retention_apply(
|
|||||||
if not p.is_dir():
|
if not p.is_dir():
|
||||||
raise ConfigError(f"Refusing to delete non-directory path: {snap_path}")
|
raise ConfigError(f"Refusing to delete non-directory path: {snap_path}")
|
||||||
|
|
||||||
# Destructive action
|
|
||||||
shutil.rmtree(p)
|
shutil.rmtree(p)
|
||||||
actions.append(f"deleted {snap_kind} {dirname}")
|
actions.append(f"deleted {snap_kind} {dirname}")
|
||||||
|
|
||||||
@@ -94,3 +94,10 @@ def run_retention_apply(
|
|||||||
"actions": actions,
|
"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()
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from ..commands.retention_apply import run_retention_apply
|
||||||
from ..config.load import load_global_config, load_host_config
|
from ..config.load import load_global_config, load_host_config
|
||||||
from ..config.merge import build_effective_config
|
from ..config.merge import build_effective_config
|
||||||
from ..errors import ConfigError
|
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
|
kind = base_dir.parent.name
|
||||||
if kind not in ("scheduled", "manual"):
|
if kind not in ("scheduled", "manual"):
|
||||||
# Should not happen with current selection logic, but keep meta robust.
|
|
||||||
kind = "unknown"
|
kind = "unknown"
|
||||||
|
|
||||||
return {
|
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)
|
host = sanitize_host(host)
|
||||||
paths = PobsyncPaths(home=prefix)
|
paths = PobsyncPaths(home=prefix)
|
||||||
|
|
||||||
|
if prune_max_delete < 0:
|
||||||
|
raise ConfigError("--prune-max-delete must be >= 0")
|
||||||
|
|
||||||
# Load and merge config
|
# Load and merge config
|
||||||
global_cfg = load_global_config(paths.global_config_path)
|
global_cfg = load_global_config(paths.global_config_path)
|
||||||
host_cfg = load_host_config(paths.hosts_dir / f"{host}.yaml")
|
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"
|
meta_path = meta_dir / "meta.yaml"
|
||||||
log_path = meta_dir / "rsync.log"
|
log_path = meta_dir / "rsync.log"
|
||||||
|
|
||||||
# Pre-build command so we can record it in metadata.
|
|
||||||
dest = str(data_dir) + "/"
|
dest = str(data_dir) + "/"
|
||||||
cmd = build_rsync_command(
|
cmd = build_rsync_command(
|
||||||
rsync_binary=str(rsync_binary),
|
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,
|
"duration_seconds": None,
|
||||||
"base": _base_meta_from_path(base_dir),
|
"base": _base_meta_from_path(base_dir),
|
||||||
"rsync": {"exit_code": None, "command": cmd, "stats": {}},
|
"rsync": {"exit_code": None, "command": cmd, "stats": {}},
|
||||||
# Keep existing fields for future expansion / compatibility with current structure.
|
|
||||||
"overrides": {"includes": [], "excludes": [], "base": None},
|
"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
|
final_dir = dirs.scheduled / snap_name
|
||||||
incomplete_dir.rename(final_dir)
|
incomplete_dir.rename(final_dir)
|
||||||
|
|
||||||
return {
|
out: dict[str, Any] = {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"dry_run": False,
|
"dry_run": False,
|
||||||
"host": host,
|
"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},
|
"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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user