diff --git a/src/pobsync_backend/management/commands/run_pobsync_backup.py b/src/pobsync_backend/management/commands/run_pobsync_backup.py index 018e9ad..9933edd 100644 --- a/src/pobsync_backend/management/commands/run_pobsync_backup.py +++ b/src/pobsync_backend/management/commands/run_pobsync_backup.py @@ -11,6 +11,7 @@ from pobsync.commands.run_scheduled import run_scheduled from pobsync.paths import PobsyncPaths from pobsync_backend.config_source import DjangoConfigSource from pobsync_backend.models import BackupRun, HostConfig +from pobsync_backend.snapshot_discovery import infer_snapshot_kind, upsert_snapshot_record class Command(BaseCommand): @@ -73,6 +74,13 @@ class Command(BaseCommand): "result", ], ) + if run.snapshot_path: + snapshot_path = Path(run.snapshot_path) + try: + kind = infer_snapshot_kind(snapshot_path) + upsert_snapshot_record(host=host, kind=kind, snapshot_dir=snapshot_path) + except ValueError: + pass if result.get("ok"): self.stdout.write(self.style.SUCCESS(f"Backup completed for {host.host}.")) diff --git a/src/pobsync_backend/snapshot_discovery.py b/src/pobsync_backend/snapshot_discovery.py index 288cf7f..a602ae1 100644 --- a/src/pobsync_backend/snapshot_discovery.py +++ b/src/pobsync_backend/snapshot_discovery.py @@ -45,19 +45,10 @@ def discover_snapshots( host_root = resolve_host_root(global_config.backup_root, host_config.host) for kind in kinds: for snapshot_dir in iter_snapshot_dirs(host_root, kind): - meta = read_snapshot_meta(snapshot_dir) - defaults = { - "path": str(snapshot_dir), - "status": str(meta.get("status") or ""), - "started_at": parse_snapshot_datetime(snapshot_dir.name, meta, "started_at"), - "ended_at": parse_snapshot_datetime(snapshot_dir.name, meta, "ended_at"), - "metadata": meta, - } - _record, was_created = SnapshotRecord.objects.update_or_create( + _record, was_created = upsert_snapshot_record( host=host_config, kind=kind, - dirname=snapshot_dir.name, - defaults=defaults, + snapshot_dir=snapshot_dir, ) scanned += 1 if was_created: @@ -73,6 +64,34 @@ def discover_snapshots( } +def upsert_snapshot_record(*, host: HostConfig, kind: str, snapshot_dir: Path) -> tuple[SnapshotRecord, bool]: + meta = read_snapshot_meta(snapshot_dir) + defaults = { + "path": str(snapshot_dir), + "status": str(meta.get("status") or ""), + "started_at": parse_snapshot_datetime(snapshot_dir.name, meta, "started_at"), + "ended_at": parse_snapshot_datetime(snapshot_dir.name, meta, "ended_at"), + "metadata": meta, + } + return SnapshotRecord.objects.update_or_create( + host=host, + kind=kind, + dirname=snapshot_dir.name, + defaults=defaults, + ) + + +def infer_snapshot_kind(snapshot_path: Path) -> str: + parent = snapshot_path.parent.name + if parent == "scheduled": + return "scheduled" + if parent == "manual": + return "manual" + if parent == ".incomplete": + return "incomplete" + raise ValueError(f"Cannot infer snapshot kind from path: {snapshot_path}") + + def _parse_iso_z(value: str) -> datetime | None: try: if value.endswith("Z"): diff --git a/src/pobsync_backend/tests/test_run_backup_records_snapshot.py b/src/pobsync_backend/tests/test_run_backup_records_snapshot.py new file mode 100644 index 0000000..7bb8da1 --- /dev/null +++ b/src/pobsync_backend/tests/test_run_backup_records_snapshot.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +from io import StringIO +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import patch + +from django.core.management import call_command +from django.core.management.base import CommandError +from django.test import TestCase + +from pobsync.util import write_yaml_atomic +from pobsync_backend.models import BackupRun, GlobalConfig, HostConfig, SnapshotRecord + + +class RunBackupRecordsSnapshotTests(TestCase): + def test_successful_backup_upserts_scheduled_snapshot_record(self) -> None: + with TemporaryDirectory() as tmp: + backup_root = Path(tmp) / "backups" + GlobalConfig.objects.create(name="default", backup_root=str(backup_root)) + host = HostConfig.objects.create(host="web-01", address="web-01.example.test") + snapshot_dir = backup_root / host.host / "scheduled" / "20260519-021500Z__ABCDEFGH" + meta_dir = snapshot_dir / "meta" + meta_dir.mkdir(parents=True) + write_yaml_atomic( + meta_dir / "meta.yaml", + { + "status": "success", + "started_at": "2026-05-19T02:15:00Z", + "ended_at": "2026-05-19T02:16:00Z", + }, + ) + + with patch("pobsync_backend.management.commands.run_pobsync_backup.run_scheduled") as run_scheduled: + run_scheduled.return_value = { + "ok": True, + "dry_run": False, + "host": host.host, + "snapshot": str(snapshot_dir), + "base": None, + "rsync": {"exit_code": 0}, + } + call_command("run_pobsync_backup", host.host, prefix=str(Path(tmp) / "home"), stdout=StringIO()) + + self.assertEqual(BackupRun.objects.count(), 1) + self.assertEqual(SnapshotRecord.objects.count(), 1) + record = SnapshotRecord.objects.get() + self.assertEqual(record.host, host) + self.assertEqual(record.kind, "scheduled") + self.assertEqual(record.status, "success") + + def test_failed_backup_upserts_incomplete_snapshot_record(self) -> None: + with TemporaryDirectory() as tmp: + backup_root = Path(tmp) / "backups" + GlobalConfig.objects.create(name="default", backup_root=str(backup_root)) + host = HostConfig.objects.create(host="web-01", address="web-01.example.test") + snapshot_dir = backup_root / host.host / ".incomplete" / "20260519-021500Z__ABCDEFGH" + meta_dir = snapshot_dir / "meta" + meta_dir.mkdir(parents=True) + write_yaml_atomic(meta_dir / "meta.yaml", {"status": "failed", "started_at": "2026-05-19T02:15:00Z"}) + + with patch("pobsync_backend.management.commands.run_pobsync_backup.run_scheduled") as run_scheduled: + run_scheduled.return_value = { + "ok": False, + "dry_run": False, + "host": host.host, + "snapshot": str(snapshot_dir), + "status": "failed", + "rsync": {"exit_code": 12}, + } + with self.assertRaises(CommandError): + call_command("run_pobsync_backup", host.host, prefix=str(Path(tmp) / "home"), stdout=StringIO()) + + run = BackupRun.objects.get() + self.assertEqual(run.status, BackupRun.Status.FAILED) + record = SnapshotRecord.objects.get() + self.assertEqual(record.kind, "incomplete") + self.assertEqual(record.status, "failed") + + def test_dry_run_does_not_create_snapshot_record(self) -> None: + with TemporaryDirectory() as tmp: + GlobalConfig.objects.create(name="default", backup_root=str(Path(tmp) / "backups")) + host = HostConfig.objects.create(host="web-01", address="web-01.example.test") + + with patch("pobsync_backend.management.commands.run_pobsync_backup.run_scheduled") as run_scheduled: + run_scheduled.return_value = { + "ok": True, + "dry_run": True, + "host": host.host, + "base": None, + "rsync": {"exit_code": 0}, + } + call_command( + "run_pobsync_backup", + host.host, + prefix=str(Path(tmp) / "home"), + dry_run=True, + stdout=StringIO(), + ) + + self.assertEqual(BackupRun.objects.count(), 1) + self.assertEqual(SnapshotRecord.objects.count(), 0)