From 100215bf118ae26970da6587b5dcf1d8854af2c0 Mon Sep 17 00:00:00 2001 From: Peter van Arkel Date: Tue, 19 May 2026 05:00:15 +0200 Subject: [PATCH] refactor: use injected config sources for retention Allow retention planning and pruning to use the same ConfigSource abstraction as scheduled backups. This removes the remaining SQL-to-YAML export dependency from Django backup runs with pruning, keeping YAML only as a legacy CLI compatibility path. --- README.md | 2 +- src/pobsync/commands/retention_apply.py | 11 +++- src/pobsync/commands/retention_plan.py | 17 +++--- src/pobsync/commands/run_scheduled.py | 1 + .../management/commands/run_pobsync_backup.py | 6 --- .../tests/test_retention_config_source.py | 53 +++++++++++++++++++ 6 files changed, 74 insertions(+), 16 deletions(-) create mode 100644 src/pobsync_backend/tests/test_retention_config_source.py diff --git a/README.md b/README.md index 913fc8e..269bb9a 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,7 @@ Run a backup through Django while still using the existing pobsync engine: python3 manage.py run_pobsync_backup --prefix /opt/pobsync --prune ``` -The Django backup command reads config from SQL directly. Runtime YAML export is kept as a compatibility tool for older CLI flows and retention planning during the transition. +The Django backup command reads backup and retention config from SQL directly. Runtime YAML export is kept as a compatibility tool for older CLI flows during the transition. Export database configs to the runtime YAML files consumed by the current engine: diff --git a/src/pobsync/commands/retention_apply.py b/src/pobsync/commands/retention_apply.py index 7f2532f..87deb50 100644 --- a/src/pobsync/commands/retention_apply.py +++ b/src/pobsync/commands/retention_apply.py @@ -4,6 +4,7 @@ import shutil from pathlib import Path from typing import Any, Dict, List +from ..config.source import ConfigSource from ..errors import ConfigError from ..lock import acquire_host_lock from ..paths import PobsyncPaths @@ -19,6 +20,7 @@ def run_retention_apply( yes: bool, max_delete: int, acquire_lock: bool = True, + config_source: ConfigSource | None = None, ) -> dict[str, Any]: host = sanitize_host(host) @@ -34,7 +36,13 @@ def run_retention_apply( paths = PobsyncPaths(home=prefix) 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), + config_source=config_source, + ) delete_list = plan.get("delete") or [] if not isinstance(delete_list, list): @@ -100,4 +108,3 @@ def run_retention_apply( # Caller guarantees locking (used by run-scheduled) return _do_apply() - diff --git a/src/pobsync/commands/retention_plan.py b/src/pobsync/commands/retention_plan.py index 4c4c997..278f3e6 100644 --- a/src/pobsync/commands/retention_plan.py +++ b/src/pobsync/commands/retention_plan.py @@ -4,8 +4,7 @@ from datetime import datetime, timezone from pathlib import Path from typing import Any, Dict, List, Optional, Tuple -from ..config.load import load_global_config, load_host_config -from ..config.merge import build_effective_config +from ..config.source import ConfigSource, FileConfigSource from ..errors import ConfigError from ..paths import PobsyncPaths from ..retention import Snapshot, build_retention_plan @@ -82,7 +81,13 @@ def _apply_base_protection( return keep, reasons -def run_retention_plan(prefix: Path, host: str, kind: str, protect_bases: bool) -> dict[str, Any]: +def run_retention_plan( + prefix: Path, + host: str, + kind: str, + protect_bases: bool, + config_source: ConfigSource | None = None, +) -> dict[str, Any]: host = sanitize_host(host) if kind not in {"scheduled", "manual", "all"}: @@ -90,9 +95,8 @@ def run_retention_plan(prefix: Path, host: str, kind: str, protect_bases: bool) 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) + source = config_source or FileConfigSource(prefix=paths.home) + cfg = source.effective_config_for_host(host) retention = cfg.get("retention") if not isinstance(retention, dict): @@ -161,4 +165,3 @@ def run_retention_plan(prefix: Path, host: str, kind: str, protect_bases: bool) ], "reasons": reasons, } - diff --git a/src/pobsync/commands/run_scheduled.py b/src/pobsync/commands/run_scheduled.py index 6bc8cb5..014552e 100644 --- a/src/pobsync/commands/run_scheduled.py +++ b/src/pobsync/commands/run_scheduled.py @@ -276,6 +276,7 @@ def run_scheduled( yes=True, max_delete=10 if prune_max_delete is None else int(prune_max_delete), acquire_lock=False, + config_source=source, ) return { diff --git a/src/pobsync_backend/management/commands/run_pobsync_backup.py b/src/pobsync_backend/management/commands/run_pobsync_backup.py index d5e6266..018e9ad 100644 --- a/src/pobsync_backend/management/commands/run_pobsync_backup.py +++ b/src/pobsync_backend/management/commands/run_pobsync_backup.py @@ -9,7 +9,6 @@ from django.utils import timezone from pobsync.commands.run_scheduled import run_scheduled from pobsync.paths import PobsyncPaths -from pobsync_backend.config_repository import export_runtime_configs from pobsync_backend.config_source import DjangoConfigSource from pobsync_backend.models import BackupRun, HostConfig @@ -33,11 +32,6 @@ class Command(BaseCommand): except HostConfig.DoesNotExist as exc: raise CommandError(f"Missing enabled HostConfig {host_name!r}") from exc - # Compatibility bridge: retention planning still reads runtime YAML. - # The backup run itself receives config directly from Django. - if options["prune"]: - export_runtime_configs(prefix=paths.home, host=host.host) - run = BackupRun.objects.create( host=host, run_type=BackupRun.RunType.SCHEDULED, diff --git a/src/pobsync_backend/tests/test_retention_config_source.py b/src/pobsync_backend/tests/test_retention_config_source.py new file mode 100644 index 0000000..5a28dd7 --- /dev/null +++ b/src/pobsync_backend/tests/test_retention_config_source.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from pathlib import Path +from tempfile import TemporaryDirectory + +from django.test import SimpleTestCase + +from pobsync.commands.retention_plan import run_retention_plan +from pobsync.util import write_yaml_atomic + + +class FakeConfigSource: + def __init__(self, backup_root: str) -> None: + self.backup_root = backup_root + + def effective_config_for_host(self, host: str) -> dict: + return { + "backup_root": self.backup_root, + "host": host, + "address": "example.test", + "retention": {"daily": 1, "weekly": 0, "monthly": 0, "yearly": 0}, + } + + +class RetentionConfigSourceTests(SimpleTestCase): + def test_retention_plan_uses_injected_config_source(self) -> None: + with TemporaryDirectory() as tmp: + root = Path(tmp) / "backups" + snap_dir = root / "web-01" / "scheduled" / "20260519-021500Z__ABCDEFGH" + meta_dir = snap_dir / "meta" + meta_dir.mkdir(parents=True) + write_yaml_atomic( + meta_dir / "meta.yaml", + { + "status": "success", + "started_at": datetime(2026, 5, 19, 2, 15, tzinfo=timezone.utc) + .isoformat() + .replace("+00:00", "Z"), + }, + ) + + plan = run_retention_plan( + prefix=Path("/missing-prefix"), + host="web-01", + kind="scheduled", + protect_bases=False, + config_source=FakeConfigSource(str(root)), + ) + + self.assertTrue(plan["ok"]) + self.assertEqual(plan["keep"], ["20260519-021500Z__ABCDEFGH"]) + self.assertEqual(plan["delete"], [])