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.
This commit is contained in:
2026-05-19 05:00:15 +02:00
parent bb44f8a09c
commit 100215bf11
6 changed files with 74 additions and 16 deletions

View File

@@ -145,7 +145,7 @@ Run a backup through Django while still using the existing pobsync engine:
python3 manage.py run_pobsync_backup <host> --prefix /opt/pobsync --prune python3 manage.py run_pobsync_backup <host> --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: Export database configs to the runtime YAML files consumed by the current engine:

View File

@@ -4,6 +4,7 @@ import shutil
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List from typing import Any, Dict, List
from ..config.source import ConfigSource
from ..errors import ConfigError from ..errors import ConfigError
from ..lock import acquire_host_lock from ..lock import acquire_host_lock
from ..paths import PobsyncPaths from ..paths import PobsyncPaths
@@ -19,6 +20,7 @@ def run_retention_apply(
yes: bool, yes: bool,
max_delete: int, max_delete: int,
acquire_lock: bool = True, acquire_lock: bool = True,
config_source: ConfigSource | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
host = sanitize_host(host) host = sanitize_host(host)
@@ -34,7 +36,13 @@ def run_retention_apply(
paths = PobsyncPaths(home=prefix) paths = PobsyncPaths(home=prefix)
def _do_apply() -> dict[str, Any]: 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 [] delete_list = plan.get("delete") or []
if not isinstance(delete_list, list): if not isinstance(delete_list, list):
@@ -100,4 +108,3 @@ def run_retention_apply(
# Caller guarantees locking (used by run-scheduled) # Caller guarantees locking (used by run-scheduled)
return _do_apply() return _do_apply()

View File

@@ -4,8 +4,7 @@ from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
from ..config.load import load_global_config, load_host_config from ..config.source import ConfigSource, FileConfigSource
from ..config.merge import build_effective_config
from ..errors import ConfigError from ..errors import ConfigError
from ..paths import PobsyncPaths from ..paths import PobsyncPaths
from ..retention import Snapshot, build_retention_plan from ..retention import Snapshot, build_retention_plan
@@ -82,7 +81,13 @@ def _apply_base_protection(
return keep, reasons 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) host = sanitize_host(host)
if kind not in {"scheduled", "manual", "all"}: 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) paths = PobsyncPaths(home=prefix)
global_cfg = load_global_config(paths.global_config_path) source = config_source or FileConfigSource(prefix=paths.home)
host_cfg = load_host_config(paths.hosts_dir / f"{host}.yaml") cfg = source.effective_config_for_host(host)
cfg = build_effective_config(global_cfg, host_cfg)
retention = cfg.get("retention") retention = cfg.get("retention")
if not isinstance(retention, dict): if not isinstance(retention, dict):
@@ -161,4 +165,3 @@ def run_retention_plan(prefix: Path, host: str, kind: str, protect_bases: bool)
], ],
"reasons": reasons, "reasons": reasons,
} }

View File

@@ -276,6 +276,7 @@ def run_scheduled(
yes=True, yes=True,
max_delete=10 if prune_max_delete is None else int(prune_max_delete), max_delete=10 if prune_max_delete is None else int(prune_max_delete),
acquire_lock=False, acquire_lock=False,
config_source=source,
) )
return { return {

View File

@@ -9,7 +9,6 @@ from django.utils import timezone
from pobsync.commands.run_scheduled import run_scheduled from pobsync.commands.run_scheduled import run_scheduled
from pobsync.paths import PobsyncPaths from pobsync.paths import PobsyncPaths
from pobsync_backend.config_repository import export_runtime_configs
from pobsync_backend.config_source import DjangoConfigSource from pobsync_backend.config_source import DjangoConfigSource
from pobsync_backend.models import BackupRun, HostConfig from pobsync_backend.models import BackupRun, HostConfig
@@ -33,11 +32,6 @@ class Command(BaseCommand):
except HostConfig.DoesNotExist as exc: except HostConfig.DoesNotExist as exc:
raise CommandError(f"Missing enabled HostConfig {host_name!r}") from 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( run = BackupRun.objects.create(
host=host, host=host,
run_type=BackupRun.RunType.SCHEDULED, run_type=BackupRun.RunType.SCHEDULED,

View File

@@ -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"], [])