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:
@@ -0,0 +1,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from pobsync_backend.config_repository import export_runtime_configs
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Export Django database configs to pobsync runtime YAML files."
|
||||
|
||||
def add_arguments(self, parser) -> None:
|
||||
parser.add_argument("--prefix", default=settings.POBSYNC_HOME, help="Pobsync home directory")
|
||||
parser.add_argument("--host", default=None, help="Export only one enabled host")
|
||||
|
||||
def handle(self, *args: Any, **options: Any) -> None:
|
||||
written = export_runtime_configs(prefix=Path(options["prefix"]), host=options["host"])
|
||||
for path in written:
|
||||
self.stdout.write(str(path))
|
||||
self.stdout.write(self.style.SUCCESS(f"Exported {len(written)} config file(s)."))
|
||||
@@ -8,8 +8,8 @@ from django.core.management.base import BaseCommand, CommandError
|
||||
from django.utils import timezone
|
||||
|
||||
from pobsync.commands.run_scheduled import run_scheduled
|
||||
from pobsync.config.load import load_host_config
|
||||
from pobsync.paths import PobsyncPaths
|
||||
from pobsync_backend.config_repository import export_runtime_configs
|
||||
from pobsync_backend.models import BackupRun, HostConfig
|
||||
|
||||
|
||||
@@ -27,19 +27,12 @@ class Command(BaseCommand):
|
||||
def handle(self, *args: Any, **options: Any) -> None:
|
||||
host_name = options["host"]
|
||||
paths = PobsyncPaths(home=Path(options["prefix"]))
|
||||
host_path = paths.hosts_dir / f"{host_name}.yaml"
|
||||
if not host_path.exists():
|
||||
raise CommandError(f"Missing host config: {host_path}")
|
||||
try:
|
||||
host = HostConfig.objects.get(host=host_name, enabled=True)
|
||||
except HostConfig.DoesNotExist as exc:
|
||||
raise CommandError(f"Missing enabled HostConfig {host_name!r}") from exc
|
||||
|
||||
host_cfg = load_host_config(host_path)
|
||||
host, _created = HostConfig.objects.update_or_create(
|
||||
host=host_cfg["host"],
|
||||
defaults={
|
||||
"address": host_cfg["address"],
|
||||
"config": host_cfg,
|
||||
"enabled": True,
|
||||
},
|
||||
)
|
||||
export_runtime_configs(prefix=paths.home, host=host.host)
|
||||
|
||||
run = BackupRun.objects.create(
|
||||
host=host,
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.management import call_command
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from pobsync_backend.models import ScheduleConfig
|
||||
from pobsync_backend.scheduler import due_key, is_due
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Run due pobsync schedules from the Django database."
|
||||
|
||||
def add_arguments(self, parser) -> None:
|
||||
parser.add_argument("--prefix", default=settings.POBSYNC_HOME, help="Pobsync home directory")
|
||||
parser.add_argument("--once", action="store_true", help="Check once and exit")
|
||||
parser.add_argument("--loop", action="store_true", help="Keep checking schedules")
|
||||
parser.add_argument("--interval", type=int, default=60, help="Loop interval in seconds")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Pass --dry-run to backup runs")
|
||||
|
||||
def handle(self, *args: Any, **options: Any) -> None:
|
||||
if not options["once"] and not options["loop"]:
|
||||
options["once"] = True
|
||||
|
||||
prefix = Path(options["prefix"])
|
||||
while True:
|
||||
count = self._run_due(prefix=prefix, dry_run=bool(options["dry_run"]))
|
||||
self.stdout.write(f"Ran {count} due schedule(s).")
|
||||
if options["once"]:
|
||||
return
|
||||
time.sleep(max(1, int(options["interval"])))
|
||||
|
||||
def _run_due(self, *, prefix: Path, dry_run: bool) -> int:
|
||||
now = timezone.localtime(timezone.now())
|
||||
current_due_key = due_key(now)
|
||||
ran = 0
|
||||
|
||||
schedules = (
|
||||
ScheduleConfig.objects.select_related("host")
|
||||
.filter(enabled=True, host__enabled=True)
|
||||
.order_by("host__host")
|
||||
)
|
||||
for schedule in schedules:
|
||||
if schedule.last_due_key == current_due_key:
|
||||
continue
|
||||
if not is_due(schedule.cron_expr, now):
|
||||
continue
|
||||
|
||||
with transaction.atomic():
|
||||
locked = ScheduleConfig.objects.select_for_update().get(pk=schedule.pk)
|
||||
if locked.last_due_key == current_due_key:
|
||||
continue
|
||||
locked.last_due_key = current_due_key
|
||||
locked.last_started_at = timezone.now()
|
||||
locked.last_status = "running"
|
||||
locked.save(update_fields=["last_due_key", "last_started_at", "last_status", "updated_at"])
|
||||
|
||||
status = "success"
|
||||
try:
|
||||
call_command(
|
||||
"run_pobsync_backup",
|
||||
schedule.host.host,
|
||||
prefix=str(prefix),
|
||||
dry_run=dry_run,
|
||||
prune=schedule.prune,
|
||||
prune_max_delete=schedule.prune_max_delete,
|
||||
prune_protect_bases=schedule.prune_protect_bases,
|
||||
)
|
||||
except Exception as exc:
|
||||
status = "failed"
|
||||
self.stderr.write(f"{schedule.host.host}: {type(exc).__name__}: {exc}")
|
||||
finally:
|
||||
ScheduleConfig.objects.filter(pk=schedule.pk).update(
|
||||
last_finished_at=timezone.now(),
|
||||
last_status=status,
|
||||
)
|
||||
ran += 1
|
||||
|
||||
return ran
|
||||
Reference in New Issue
Block a user