diff --git a/src/pobsync/commands/run_scheduled.py b/src/pobsync/commands/run_scheduled.py index d170f82..e5a2b16 100644 --- a/src/pobsync/commands/run_scheduled.py +++ b/src/pobsync/commands/run_scheduled.py @@ -3,7 +3,6 @@ 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 @@ -19,6 +18,7 @@ from ..snapshot import ( snapshot_dir_name, utc_now, ) +from ..snapshot_meta import read_snapshot_meta from ..util import ensure_dir, realpath_startswith, sanitize_host, write_yaml_atomic @@ -47,7 +47,7 @@ def _find_latest_snapshot(parent: Path) -> Path | None: def select_scheduled_base(dirs: HostBackupDirs) -> Path | None: """ Base selection rule: - scheduled -> manual -> none + scheduled -> manual -> none """ base = _find_latest_snapshot(dirs.scheduled) if base is not None: @@ -55,36 +55,37 @@ def select_scheduled_base(dirs: HostBackupDirs) -> Path | None: return _find_latest_snapshot(dirs.manual) -def _base_meta_from_path(base_dir: Path | None) -> dict[str, Any] | None: +def _base_meta_from_path(base_dir: Path | None, link_dest: str | None) -> dict[str, Any] | None: + """ + Build base metadata for meta.yaml. + + Important: link_dest is the actual rsync --link-dest directory. + For our snapshot layout, that must be "/data". + """ if base_dir is None: return 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" + base_meta = read_snapshot_meta(base_dir) + base_id = base_meta.get("id") if isinstance(base_meta.get("id"), str) else None + return { "kind": kind, "dirname": base_dir.name, - "id": None, - "path": None, + "id": base_id, + "path": link_dest, } -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]: +def run_scheduled(prefix: Path, host: str, dry_run: bool, prune: bool = False, prune_max_delete: int | None = None, 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") @@ -102,7 +103,10 @@ def run_scheduled( # Base snapshot (absolute path) base_dir = select_scheduled_base(dirs) - link_dest = str(base_dir) if base_dir else None + + # BUGFIX: rsync --link-dest must point at the snapshot "data" root, not the snapshot dir itself. + # Our destination root is "/data/", so the base root must be "/data/". + link_dest = str(base_dir / "data") if base_dir else None ssh_cfg = cfg.get("ssh", {}) or {} rsync_cfg = cfg.get("rsync", {}) or {} @@ -186,6 +190,7 @@ def run_scheduled( 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), @@ -211,8 +216,9 @@ def run_scheduled( "started_at": format_iso_z(ts), "ended_at": None, "duration_seconds": None, - "base": _base_meta_from_path(base_dir), + "base": _base_meta_from_path(base_dir, link_dest), "rsync": {"exit_code": None, "command": cmd, "stats": {}}, + # Keep existing fields for future expansion / compatibility with current structure. "overrides": {"includes": [], "excludes": [], "base": None}, } @@ -253,7 +259,7 @@ def run_scheduled( final_dir = dirs.scheduled / snap_name incomplete_dir.rename(final_dir) - out: dict[str, Any] = { + return { "ok": True, "dry_run": False, "host": host, @@ -262,28 +268,3 @@ def run_scheduled( "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 -