463 lines
19 KiB
Python
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
|
|
|