Files
pobsync/src/pobsync/cli.py

463 lines
19 KiB
Python

from __future__ import annotations
import argparse
import json
from pathlib import Path
from typing import Any
from .commands.doctor import run_doctor
from .commands.init_host import run_init_host
from .commands.install import run_install
from .commands.list_remotes import run_list_remotes
from .commands.retention_apply import run_retention_apply
from .commands.retention_plan import run_retention_plan
from .commands.run_scheduled import run_scheduled
from .commands.schedule_create import run_schedule_create
from .commands.schedule_list import run_schedule_list
from .commands.schedule_remove import run_schedule_remove
from .commands.show_config import dump_yaml, run_show_config
from .commands.snapshots_list import run_snapshots_list
from .commands.snapshots_show import run_snapshots_show
from .errors import LockError, PobsyncError
from .schedule import CRON_FILE_DEFAULT
from .util import to_json_safe
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(prog="pobsync")
p.add_argument("--prefix", default="/opt/pobsync", help="Pobsync home directory (default: /opt/pobsync)")
p.add_argument("--json", action="store_true", help="Machine-readable JSON output")
sub = p.add_subparsers(dest="command", required=True)
# install
ip = sub.add_parser("install", help="Bootstrap /opt/pobsync layout and create global config")
ip.add_argument("--backup-root", help="Backup root directory (e.g. /srv/backups)")
ip.add_argument("--retention", default="daily=14,weekly=8,monthly=12,yearly=0", help="Default retention for init-host")
ip.add_argument("--force", action="store_true", help="Overwrite existing global config")
ip.add_argument("--dry-run", action="store_true", help="Show actions, do not write")
ip.set_defaults(_handler=cmd_install)
# init-host
hp = sub.add_parser("init-host", help="Create a host config YAML under config/hosts")
hp.add_argument("host", help="Host name (used as filename)")
hp.add_argument("--address", help="Hostname or IP of the remote")
hp.add_argument("--ssh-user", default=None)
hp.add_argument("--ssh-port", type=int, default=None)
hp.add_argument("--retention", default=None, help="Override retention for this host (daily=...,weekly=...)")
hp.add_argument("--exclude-add", action="append", default=[], help="Additional excludes (repeatable)")
hp.add_argument("--exclude-replace", action="append", default=None, help="Replace excludes list (repeatable)")
hp.add_argument("--include", action="append", default=[], help="Include patterns (repeatable)")
hp.add_argument("--force", action="store_true")
hp.add_argument("--dry-run", action="store_true")
hp.set_defaults(_handler=cmd_init_host)
# doctor
dp = sub.add_parser("doctor", help="Validate installation and configuration")
dp.add_argument("host", nargs="?", default=None, help="Optional host to validate")
dp.add_argument("--connect", action="store_true", help="Try SSH connectivity check (phase 2)")
dp.add_argument("--rsync-dry-run", action="store_true", help="Try rsync dry run (phase 2)")
dp.set_defaults(_handler=cmd_doctor)
# list remotes
lp = sub.add_parser("list-remotes", help="List configured remotes (host configs)")
lp.set_defaults(_handler=cmd_list_remotes)
# show config
sp = sub.add_parser("show-config", help="Show host configuration (raw or effective)")
sp.add_argument("host", help="Host to show")
sp.add_argument("--effective", action="store_true", help="Show merged effective config")
sp.set_defaults(_handler=cmd_show_config)
# run scheduled
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)")
rp.add_argument("--prune-protect-bases", action="store_true", help="When pruning, also keep base snapshots referenced in meta")
rp.set_defaults(_handler=cmd_run_scheduled)
# snapshots
sn = sub.add_parser("snapshots", help="Inspect snapshots (list/show)")
sn_sub = sn.add_subparsers(dest="snapshots_cmd", required=True)
sn_list = sn_sub.add_parser("list", help="List snapshots for a host")
sn_list.add_argument("host", help="Host name")
sn_list.add_argument("--kind", default="all", help="scheduled|manual|incomplete|all (default: all)")
sn_list.add_argument("--limit", type=int, default=20, help="Max results (default: 20)")
sn_list.add_argument("--include-incomplete", action="store_true", help="Include .incomplete when --kind=all")
sn_list.set_defaults(_handler=cmd_snapshots_list)
sn_show = sn_sub.add_parser("show", help="Show snapshot metadata")
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")
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
rt = sub.add_parser("retention", help="Retention management")
rt_sub = rt.add_subparsers(dest="retention_cmd", required=True)
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.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)")
rt_apply.add_argument("--yes", action="store_true", help="Confirm deletion")
rt_apply.set_defaults(_handler=cmd_retention_apply)
# schedule
sch = sub.add_parser("schedule", help="Manage cron schedules in /etc/cron.d/pobsync")
sch_sub = sch.add_subparsers(dest="schedule_cmd", required=True)
sch_create = sch_sub.add_parser("create", help="Create or update a schedule for a host")
sch_create.add_argument("host", help="Host name")
mode = sch_create.add_mutually_exclusive_group(required=True)
mode.add_argument("--cron", default=None, help='Raw cron expression (5 fields), e.g. "15 2 * * *"')
mode.add_argument("--daily", default=None, help="Daily at HH:MM")
mode.add_argument("--hourly", type=int, default=None, help="Hourly at minute N (0..59)")
mode.add_argument("--weekly", action="store_true", help="Weekly schedule (requires --dow and --time)")
mode.add_argument("--monthly", action="store_true", help="Monthly schedule (requires --day and --time)")
sch_create.add_argument("--dow", default=None, help="For --weekly: mon,tue,wed,thu,fri,sat,sun")
sch_create.add_argument("--day", type=int, default=None, help="For --monthly: day of month (1..31)")
sch_create.add_argument("--time", default=None, help="For --weekly/--monthly: HH:MM")
sch_create.add_argument("--user", default="root", help="Cron user field (default: root)")
sch_create.add_argument("--cron-file", default=CRON_FILE_DEFAULT, help="Cron file path (default: /etc/cron.d/pobsync)")
sch_create.add_argument("--prune", action="store_true", help="Run retention prune after successful backup")
sch_create.add_argument("--prune-max-delete", type=int, default=10, help="Prune guardrail (default: 10)")
sch_create.add_argument("--prune-protect-bases", action="store_true", help="Prune with base protection (default: false)")
sch_create.add_argument("--dry-run", action="store_true", help="Show actions, do not write")
sch_create.set_defaults(_handler=cmd_schedule_create)
sch_list = sch_sub.add_parser("list", help="List schedules from /etc/cron.d/pobsync")
sch_list.add_argument("--host", default=None, help="Filter by host")
sch_list.add_argument("--cron-file", default=CRON_FILE_DEFAULT, help="Cron file path (default: /etc/cron.d/pobsync)")
sch_list.set_defaults(_handler=cmd_schedule_list)
sch_remove = sch_sub.add_parser("remove", help="Remove schedule block for a host")
sch_remove.add_argument("host", help="Host name")
sch_remove.add_argument("--cron-file", default=CRON_FILE_DEFAULT, help="Cron file path (default: /etc/cron.d/pobsync)")
sch_remove.add_argument("--dry-run", action="store_true", help="Show actions, do not write")
sch_remove.set_defaults(_handler=cmd_schedule_remove)
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))
return
if result.get("ok") is True:
print("OK")
else:
print("FAILED")
if "actions" in result:
for a in result["actions"]:
print(f"- {a}")
if "results" in result:
for r in result["results"]:
label = "OK" if r.get("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 "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)
if "snapshot" in result:
print(f"- snapshot {result['snapshot']}")
if "base" in result and result["base"]:
print(f"- base {result['base']}")
if "snapshots" in result:
for s in result["snapshots"]:
kind = s.get("kind", "?")
dirname = s.get("dirname", "?")
status = s.get("status") or "unknown"
started_at = s.get("started_at") or ""
dur = s.get("duration_seconds")
dur_s = f"{dur}s" if isinstance(dur, int) else ""
extra = " ".join(x for x in [started_at, dur_s] if x)
if extra:
extra = " " + extra
print(f"- {kind} {dirname} {status}{extra}")
if "keep" in result and "delete" in result:
keep = result.get("keep") or []
delete = result.get("delete") or []
reasons = result.get("reasons") or {}
total = len(keep) + len(delete)
print(f"- total {total}")
print(f"- keep {len(keep)}")
print(f"- delete {len(delete)}")
if result.get("protect_bases") is True:
print("- protect_bases true")
if keep:
print("- keep:")
for d in keep:
rs = reasons.get(d) or []
rs_s = f" ({', '.join(rs)})" if rs else ""
print(f" - {d}{rs_s}")
if delete:
print("- delete:")
for item in delete:
dirname = item.get("dirname", "?")
dt = item.get("dt") or ""
status = item.get("status") or "unknown"
kind = item.get("kind", "?")
extra = " ".join(x for x in [kind, status, dt] if x)
if extra:
extra = " " + extra
print(f" - {dirname}{extra}")
if "schedules" in result:
for s in result["schedules"]:
host = s.get("host", "?")
cron = s.get("cron") or "unknown"
user = s.get("user") or "unknown"
prune = bool(s.get("prune", False))
prune_max = s.get("prune_max_delete", None)
protect = bool(s.get("prune_protect_bases", False))
extra = ""
if prune:
extra = " prune"
if isinstance(prune_max, int):
extra += f" max_delete={prune_max}"
if protect:
extra += " protect_bases"
print(f"- {host} {cron} {user}{extra}")
def cmd_install(args: argparse.Namespace) -> int:
prefix = Path(args.prefix)
retention = parse_retention(args.retention)
result = run_install(
prefix=prefix,
backup_root=args.backup_root,
retention=retention,
dry_run=bool(args.dry_run),
force=bool(args.force),
)
_print(result, as_json=bool(args.json))
return 0 if result.get("ok") else 1
def cmd_init_host(args: argparse.Namespace) -> int:
prefix = Path(args.prefix)
result = run_init_host(
prefix=prefix,
host=args.host,
address=args.address,
retention=args.retention,
ssh_user=args.ssh_user,
ssh_port=args.ssh_port,
excludes_add=list(args.exclude_add),
excludes_replace=args.exclude_replace,
includes=list(args.include),
dry_run=bool(args.dry_run),
force=bool(args.force),
)
_print(result, as_json=bool(args.json))
return 0 if result.get("ok") else 1
def cmd_doctor(args: argparse.Namespace) -> int:
prefix = Path(args.prefix)
result = run_doctor(prefix=prefix, host=args.host, connect=bool(args.connect), rsync_dry_run=bool(args.rsync_dry_run))
_print(result, as_json=bool(args.json))
return 0 if result.get("ok") else 1
def cmd_list_remotes(args: argparse.Namespace) -> int:
prefix = Path(args.prefix)
result = run_list_remotes(prefix=prefix)
_print(result, as_json=bool(args.json))
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),
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
def cmd_snapshots_list(args: argparse.Namespace) -> int:
prefix = Path(args.prefix)
result = run_snapshots_list(
prefix=prefix,
host=args.host,
kind=args.kind,
limit=int(args.limit),
include_incomplete=bool(args.include_incomplete),
)
_print(result, as_json=bool(args.json))
return 0 if result.get("ok") else 1
def cmd_snapshots_show(args: argparse.Namespace) -> int:
prefix = Path(args.prefix)
result = run_snapshots_show(prefix=prefix, host=args.host, kind=args.kind, dirname=args.dirname, tail=args.tail)
if args.json:
_print(result, as_json=True)
else:
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"]:
print(line)
return 0 if result.get("ok") else 1
def cmd_retention_plan(args: argparse.Namespace) -> int:
prefix = Path(args.prefix)
result = run_retention_plan(prefix=prefix, host=args.host, kind=args.kind, protect_bases=bool(args.protect_bases))
_print(result, as_json=bool(args.json))
return 0 if result.get("ok") else 1
def cmd_retention_apply(args: argparse.Namespace) -> int:
prefix = Path(args.prefix)
result = run_retention_apply(
prefix=prefix,
host=args.host,
kind=args.kind,
protect_bases=bool(args.protect_bases),
yes=bool(args.yes),
max_delete=int(args.max_delete),
)
_print(result, as_json=bool(args.json))
return 0 if result.get("ok") else 1
def cmd_schedule_create(args: argparse.Namespace) -> int:
prefix = Path(args.prefix)
result = run_schedule_create(
host=args.host,
prefix=prefix,
cron_file=Path(args.cron_file),
cron_expr=args.cron,
daily=args.daily,
hourly=args.hourly,
weekly=bool(args.weekly),
dow=args.dow,
time=args.time,
monthly=bool(args.monthly),
day=args.day,
user=args.user,
prune=bool(args.prune),
prune_max_delete=int(args.prune_max_delete),
prune_protect_bases=bool(args.prune_protect_bases),
dry_run=bool(args.dry_run),
)
_print(result, as_json=bool(args.json))
return 0 if result.get("ok") else 1
def cmd_schedule_list(args: argparse.Namespace) -> int:
result = run_schedule_list(cron_file=Path(args.cron_file), host=args.host)
_print(result, as_json=bool(args.json))
return 0 if result.get("ok") else 1
def cmd_schedule_remove(args: argparse.Namespace) -> int:
result = run_schedule_remove(host=args.host, cron_file=Path(args.cron_file), dry_run=bool(args.dry_run))
_print(result, as_json=bool(args.json))
return 0 if result.get("ok") else 1
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
try:
handler = getattr(args, "_handler")
return int(handler(args))
except PobsyncError as e:
if args.json:
_print({"ok": False, "error": str(e), "type": type(e).__name__}, as_json=True)
else:
print(f"ERROR: {e}")
if isinstance(e, LockError):
return 10
return 1
except KeyboardInterrupt:
if args.json:
_print({"ok": False, "error": "interrupted"}, as_json=True)
else:
print("ERROR: interrupted")
return 130