feat: make Django configs drive backups and scheduling
Treat SQL-backed Django models as the source of truth for pobsync configuration, exporting runtime YAML only as a compatibility layer for the existing engine. Add a database-driven scheduler command, Docker scheduler services, schedule run-state fields, and tests for scheduler, config export, and retention behavior.
This commit is contained in:
1
src/pobsync_backend/tests/__init__.py
Normal file
1
src/pobsync_backend/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
47
src/pobsync_backend/tests/test_config_repository.py
Normal file
47
src/pobsync_backend/tests/test_config_repository.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from pobsync.config.load import load_global_config, load_host_config
|
||||
from pobsync_backend.config_repository import export_runtime_configs
|
||||
from pobsync_backend.models import GlobalConfig, HostConfig
|
||||
|
||||
|
||||
class ConfigRepositoryTests(TestCase):
|
||||
def test_exports_database_configs_to_engine_yaml(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
prefix = Path(tmp)
|
||||
GlobalConfig.objects.create(
|
||||
name="default",
|
||||
backup_root="/backups",
|
||||
pobsync_home=str(prefix),
|
||||
data={
|
||||
"backup_root": "/ignored",
|
||||
"pobsync_home": "/ignored",
|
||||
"retention_defaults": {"daily": 7, "weekly": 4, "monthly": 3, "yearly": 1},
|
||||
},
|
||||
)
|
||||
HostConfig.objects.create(
|
||||
host="web-01",
|
||||
address="web-01.example.test",
|
||||
config={
|
||||
"host": "ignored",
|
||||
"address": "ignored",
|
||||
"retention": {"daily": 7, "weekly": 4, "monthly": 3, "yearly": 1},
|
||||
"includes": [],
|
||||
"excludes_add": ["/tmp/***"],
|
||||
},
|
||||
)
|
||||
|
||||
written = export_runtime_configs(prefix=prefix, host="web-01")
|
||||
|
||||
self.assertEqual(len(written), 2)
|
||||
global_cfg = load_global_config(prefix / "config" / "global.yaml")
|
||||
host_cfg = load_host_config(prefix / "config" / "hosts" / "web-01.yaml")
|
||||
self.assertEqual(global_cfg["backup_root"], "/backups")
|
||||
self.assertEqual(global_cfg["pobsync_home"], str(prefix))
|
||||
self.assertEqual(host_cfg["host"], "web-01")
|
||||
self.assertEqual(host_cfg["address"], "web-01.example.test")
|
||||
24
src/pobsync_backend/tests/test_retention.py
Normal file
24
src/pobsync_backend/tests/test_retention.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from django.test import SimpleTestCase
|
||||
|
||||
from pobsync.retention import Snapshot, build_retention_plan
|
||||
|
||||
|
||||
class RetentionTests(SimpleTestCase):
|
||||
def test_always_keeps_newest_snapshot(self) -> None:
|
||||
snapshots = [
|
||||
Snapshot("scheduled", "old", "/x/old", datetime(2026, 5, 18, tzinfo=timezone.utc), "success", None),
|
||||
Snapshot("scheduled", "new", "/x/new", datetime(2026, 5, 19, tzinfo=timezone.utc), "failed", None),
|
||||
]
|
||||
|
||||
plan = build_retention_plan(
|
||||
snapshots,
|
||||
retention={"daily": 0, "weekly": 0, "monthly": 0, "yearly": 0},
|
||||
now=datetime(2026, 5, 19, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
self.assertEqual(plan.keep, {"new"})
|
||||
self.assertEqual(plan.reasons["new"], ["newest"])
|
||||
60
src/pobsync_backend/tests/test_scheduler.py
Normal file
60
src/pobsync_backend/tests/test_scheduler.py
Normal file
@@ -0,0 +1,60 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from django.test import SimpleTestCase, TestCase
|
||||
|
||||
from pobsync_backend.management.commands.run_pobsync_scheduler import Command
|
||||
from pobsync_backend.models import HostConfig, ScheduleConfig
|
||||
from pobsync_backend.scheduler import due_key, is_due
|
||||
|
||||
|
||||
class SchedulerTests(SimpleTestCase):
|
||||
def test_daily_time_is_due_only_on_matching_minute(self) -> None:
|
||||
moment = datetime(2026, 5, 19, 2, 15, tzinfo=ZoneInfo("UTC"))
|
||||
|
||||
self.assertTrue(is_due("15 2 * * *", moment))
|
||||
self.assertFalse(is_due("16 2 * * *", moment))
|
||||
|
||||
def test_step_values_are_supported(self) -> None:
|
||||
moment = datetime(2026, 5, 19, 2, 30, tzinfo=ZoneInfo("UTC"))
|
||||
|
||||
self.assertTrue(is_due("*/15 * * * *", moment))
|
||||
self.assertFalse(is_due("*/20 * * * *", moment))
|
||||
|
||||
def test_sunday_allows_zero_and_seven(self) -> None:
|
||||
sunday = datetime(2026, 5, 24, 2, 0, tzinfo=ZoneInfo("UTC"))
|
||||
|
||||
self.assertTrue(is_due("0 2 * * 0", sunday))
|
||||
self.assertTrue(is_due("0 2 * * 7", sunday))
|
||||
|
||||
def test_due_key_has_minute_granularity(self) -> None:
|
||||
moment = datetime(2026, 5, 19, 2, 15, 45, tzinfo=ZoneInfo("UTC"))
|
||||
|
||||
self.assertEqual(due_key(moment), "202605190215")
|
||||
|
||||
|
||||
class SchedulerCommandTests(TestCase):
|
||||
def test_run_due_executes_schedule_once_per_minute(self) -> None:
|
||||
host = HostConfig.objects.create(
|
||||
host="web-01",
|
||||
address="web-01.example.test",
|
||||
config={
|
||||
"retention": {"daily": 7, "weekly": 4, "monthly": 3, "yearly": 1},
|
||||
},
|
||||
)
|
||||
ScheduleConfig.objects.create(host=host, cron_expr="* * * * *")
|
||||
|
||||
command = Command()
|
||||
with patch("pobsync_backend.management.commands.run_pobsync_scheduler.call_command") as call:
|
||||
first_count = command._run_due(prefix=Path("/opt/pobsync"), dry_run=True)
|
||||
second_count = command._run_due(prefix=Path("/opt/pobsync"), dry_run=True)
|
||||
|
||||
self.assertEqual(first_count, 1)
|
||||
self.assertEqual(second_count, 0)
|
||||
self.assertEqual(call.call_count, 1)
|
||||
schedule = ScheduleConfig.objects.get(host=host)
|
||||
self.assertEqual(schedule.last_status, "success")
|
||||
Reference in New Issue
Block a user