feat(retention): show keep/delete details in human output
This commit is contained in:
@@ -9,11 +9,12 @@ from .commands.doctor import run_doctor
|
|||||||
from .commands.init_host import run_init_host
|
from .commands.init_host import run_init_host
|
||||||
from .commands.install import run_install
|
from .commands.install import run_install
|
||||||
from .commands.list_remotes import run_list_remotes
|
from .commands.list_remotes import run_list_remotes
|
||||||
|
from .commands.retention_plan import run_retention_plan
|
||||||
from .commands.run_scheduled import run_scheduled
|
from .commands.run_scheduled import run_scheduled
|
||||||
from .commands.show_config import dump_yaml, run_show_config
|
from .commands.show_config import dump_yaml, run_show_config
|
||||||
from .commands.snapshots_list import run_snapshots_list
|
from .commands.snapshots_list import run_snapshots_list
|
||||||
from .commands.snapshots_show import run_snapshots_show
|
from .commands.snapshots_show import run_snapshots_show
|
||||||
from .errors import ConfigError, DoctorError, InstallError, LockError, PobsyncError
|
from .errors import ConfigError, LockError, PobsyncError
|
||||||
from .paths import PobsyncPaths
|
from .paths import PobsyncPaths
|
||||||
from .util import is_tty, to_json_safe
|
from .util import is_tty, to_json_safe
|
||||||
|
|
||||||
@@ -77,16 +78,34 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
sn_list.add_argument("host", help="Host name")
|
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("--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("--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 (default: false)")
|
sn_list.add_argument(
|
||||||
|
"--include-incomplete",
|
||||||
|
action="store_true",
|
||||||
|
help="Include .incomplete when --kind=all (default: false)",
|
||||||
|
)
|
||||||
sn_list.set_defaults(_handler=cmd_snapshots_list)
|
sn_list.set_defaults(_handler=cmd_snapshots_list)
|
||||||
|
|
||||||
sn_show = sn_sub.add_parser("show", help="Show snapshot metadata")
|
sn_show = sn_sub.add_parser("show", help="Show snapshot metadata")
|
||||||
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("--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)
|
sn_show.set_defaults(_handler=cmd_snapshots_show)
|
||||||
|
|
||||||
|
# retention
|
||||||
|
rt = sub.add_parser("retention", help="Retention management (dry-run)")
|
||||||
|
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.set_defaults(_handler=cmd_retention_plan)
|
||||||
|
|
||||||
return p
|
return p
|
||||||
|
|
||||||
|
|
||||||
@@ -170,6 +189,36 @@ def _print(result: dict[str, Any], as_json: bool) -> None:
|
|||||||
extra = " " + extra
|
extra = " " + extra
|
||||||
print(f"- {kind} {dirname} {status}{extra}")
|
print(f"- {kind} {dirname} {status}{extra}")
|
||||||
|
|
||||||
|
# retention plan
|
||||||
|
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 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}")
|
||||||
|
|
||||||
|
|
||||||
def cmd_install(args: argparse.Namespace) -> int:
|
def cmd_install(args: argparse.Namespace) -> int:
|
||||||
prefix = Path(args.prefix)
|
prefix = Path(args.prefix)
|
||||||
@@ -308,6 +357,13 @@ def cmd_snapshots_show(args: argparse.Namespace) -> int:
|
|||||||
return 0 if result.get("ok") else 1
|
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)
|
||||||
|
_print(result, as_json=bool(args.json))
|
||||||
|
return 0 if result.get("ok") else 1
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
def main(argv: list[str] | None = None) -> int:
|
||||||
parser = build_parser()
|
parser = build_parser()
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
|
|||||||
104
src/pobsync/commands/retention_plan.py
Normal file
104
src/pobsync/commands/retention_plan.py
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
from ..config.load import load_global_config, load_host_config
|
||||||
|
from ..config.merge import build_effective_config
|
||||||
|
from ..errors import ConfigError
|
||||||
|
from ..paths import PobsyncPaths
|
||||||
|
from ..retention import Snapshot, build_retention_plan
|
||||||
|
from ..snapshot_meta import iter_snapshot_dirs, read_snapshot_meta, resolve_host_root
|
||||||
|
from ..util import sanitize_host
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_snapshot_dt(dirname: str, meta: dict) -> datetime:
|
||||||
|
ts = meta.get("started_at")
|
||||||
|
if isinstance(ts, str) and ts.endswith("Z"):
|
||||||
|
try:
|
||||||
|
return datetime.strptime(ts, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# fallback: dirname YYYYMMDD-HHMMSSZ__ID
|
||||||
|
try:
|
||||||
|
prefix = dirname.split("__", 1)[0]
|
||||||
|
return datetime.strptime(prefix, "%Y%m%d-%H%M%SZ").replace(tzinfo=timezone.utc)
|
||||||
|
except Exception:
|
||||||
|
return datetime.fromtimestamp(0, tz=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def run_retention_plan(prefix: Path, host: str, kind: str) -> dict[str, Any]:
|
||||||
|
host = sanitize_host(host)
|
||||||
|
|
||||||
|
if kind not in {"scheduled", "manual", "all"}:
|
||||||
|
raise ConfigError("kind must be scheduled, manual, or all")
|
||||||
|
|
||||||
|
paths = PobsyncPaths(home=prefix)
|
||||||
|
|
||||||
|
global_cfg = load_global_config(paths.global_config_path)
|
||||||
|
host_cfg = load_host_config(paths.hosts_dir / f"{host}.yaml")
|
||||||
|
cfg = build_effective_config(global_cfg, host_cfg)
|
||||||
|
|
||||||
|
retention = cfg.get("retention")
|
||||||
|
if not isinstance(retention, dict):
|
||||||
|
raise ConfigError("No retention config found")
|
||||||
|
|
||||||
|
backup_root = cfg.get("backup_root")
|
||||||
|
if not isinstance(backup_root, str) or not backup_root.startswith("/"):
|
||||||
|
raise ConfigError("Invalid backup_root in config")
|
||||||
|
|
||||||
|
host_root = resolve_host_root(backup_root, host)
|
||||||
|
|
||||||
|
kinds: List[str]
|
||||||
|
if kind == "all":
|
||||||
|
kinds = ["scheduled", "manual"]
|
||||||
|
else:
|
||||||
|
kinds = [kind]
|
||||||
|
|
||||||
|
snapshots: List[Snapshot] = []
|
||||||
|
|
||||||
|
for kk in kinds:
|
||||||
|
for d in iter_snapshot_dirs(host_root, kk):
|
||||||
|
meta = read_snapshot_meta(d)
|
||||||
|
dt = _parse_snapshot_dt(d.name, meta)
|
||||||
|
|
||||||
|
snapshots.append(
|
||||||
|
Snapshot(
|
||||||
|
kind=kk,
|
||||||
|
dirname=d.name,
|
||||||
|
path=str(d),
|
||||||
|
dt=dt,
|
||||||
|
status=meta.get("status"),
|
||||||
|
base=meta.get("base"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
plan = build_retention_plan(
|
||||||
|
snapshots=snapshots,
|
||||||
|
retention=retention,
|
||||||
|
now=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
delete = [s for s in snapshots if s.dirname not in plan.keep]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"host": host,
|
||||||
|
"kind": kind,
|
||||||
|
"retention": retention,
|
||||||
|
"keep": sorted(plan.keep),
|
||||||
|
"delete": [
|
||||||
|
{
|
||||||
|
"dirname": s.dirname,
|
||||||
|
"kind": s.kind,
|
||||||
|
"path": s.path,
|
||||||
|
"dt": s.dt.isoformat(),
|
||||||
|
"status": s.status,
|
||||||
|
}
|
||||||
|
for s in delete
|
||||||
|
],
|
||||||
|
"reasons": plan.reasons,
|
||||||
|
}
|
||||||
|
|
||||||
125
src/pobsync/retention.py
Normal file
125
src/pobsync/retention.py
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any, Dict, Iterable, List, Optional, Set, Tuple
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Snapshot:
|
||||||
|
kind: str # scheduled | manual
|
||||||
|
dirname: str
|
||||||
|
path: str
|
||||||
|
dt: datetime # UTC
|
||||||
|
status: Optional[str]
|
||||||
|
base: Optional[dict]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RetentionResult:
|
||||||
|
keep: Set[str] # dirnames
|
||||||
|
reasons: Dict[str, List[str]]
|
||||||
|
|
||||||
|
|
||||||
|
def _bucket_day(dt: datetime) -> str:
|
||||||
|
return dt.strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
|
||||||
|
def _bucket_week(dt: datetime) -> str:
|
||||||
|
iso = dt.isocalendar()
|
||||||
|
return f"{iso.year}-W{iso.week:02d}"
|
||||||
|
|
||||||
|
|
||||||
|
def _bucket_month(dt: datetime) -> str:
|
||||||
|
return dt.strftime("%Y-%m")
|
||||||
|
|
||||||
|
|
||||||
|
def _bucket_year(dt: datetime) -> str:
|
||||||
|
return dt.strftime("%Y")
|
||||||
|
|
||||||
|
|
||||||
|
def _window_start(now: datetime, unit: str, count: int) -> datetime:
|
||||||
|
if count <= 0:
|
||||||
|
return now + timedelta(days=1)
|
||||||
|
|
||||||
|
if unit == "daily":
|
||||||
|
return (now - timedelta(days=count - 1)).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
if unit == "weekly":
|
||||||
|
return now - timedelta(weeks=count - 1)
|
||||||
|
if unit == "monthly":
|
||||||
|
return now.replace(day=1) - timedelta(days=32 * (count - 1))
|
||||||
|
if unit == "yearly":
|
||||||
|
return now.replace(month=1, day=1) - timedelta(days=366 * (count - 1))
|
||||||
|
|
||||||
|
raise ValueError(unit)
|
||||||
|
|
||||||
|
|
||||||
|
def build_retention_plan(
|
||||||
|
snapshots: Iterable[Snapshot],
|
||||||
|
retention: Dict[str, int],
|
||||||
|
now: Optional[datetime] = None,
|
||||||
|
) -> RetentionResult:
|
||||||
|
"""
|
||||||
|
Build a dry-run retention plan.
|
||||||
|
Returns:
|
||||||
|
- keep: set of snapshot dirnames to keep
|
||||||
|
- reasons: mapping dirname -> list of reasons why it is kept
|
||||||
|
"""
|
||||||
|
if now is None:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
snaps = sorted(snapshots, key=lambda s: s.dt, reverse=True)
|
||||||
|
|
||||||
|
keep: Set[str] = set()
|
||||||
|
reasons: Dict[str, List[str]] = {}
|
||||||
|
|
||||||
|
def mark(dirname: str, reason: str) -> None:
|
||||||
|
keep.add(dirname)
|
||||||
|
reasons.setdefault(dirname, []).append(reason)
|
||||||
|
|
||||||
|
# Always keep newest snapshot overall (if any)
|
||||||
|
if snaps:
|
||||||
|
mark(snaps[0].dirname, "newest")
|
||||||
|
|
||||||
|
# Retention buckets
|
||||||
|
rules = [
|
||||||
|
("daily", retention.get("daily", 0), _bucket_day),
|
||||||
|
("weekly", retention.get("weekly", 0), _bucket_week),
|
||||||
|
("monthly", retention.get("monthly", 0), _bucket_month),
|
||||||
|
("yearly", retention.get("yearly", 0), _bucket_year),
|
||||||
|
]
|
||||||
|
|
||||||
|
for name, count, bucket_fn in rules:
|
||||||
|
if count <= 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
window_start = _window_start(now, name, count)
|
||||||
|
seen: Set[str] = set()
|
||||||
|
|
||||||
|
for s in snaps:
|
||||||
|
if s.dt < window_start:
|
||||||
|
break
|
||||||
|
|
||||||
|
bucket = bucket_fn(s.dt)
|
||||||
|
if bucket in seen:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Prefer successful snapshots, but allow fallback
|
||||||
|
if s.status not in (None, "success"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
seen.add(bucket)
|
||||||
|
mark(s.dirname, f"{name}:{bucket}")
|
||||||
|
|
||||||
|
# Fallback: if a bucket had no success, allow newest non-success
|
||||||
|
for s in snaps:
|
||||||
|
if s.dt < window_start:
|
||||||
|
break
|
||||||
|
bucket = bucket_fn(s.dt)
|
||||||
|
if bucket in seen:
|
||||||
|
continue
|
||||||
|
seen.add(bucket)
|
||||||
|
mark(s.dirname, f"{name}:{bucket}:fallback")
|
||||||
|
|
||||||
|
return RetentionResult(keep=keep, reasons=reasons)
|
||||||
|
|
||||||
Reference in New Issue
Block a user