feat: record backup snapshots during run completion
Upsert SnapshotRecord rows directly from run_pobsync_backup results so new successful and failed backup runs are reflected in the database without requiring a separate discovery pass. Keep discovery for existing snapshots and repair workflows, and cover success, failure, and dry-run behavior with tests.
This commit is contained in:
@@ -11,6 +11,7 @@ from pobsync.commands.run_scheduled import run_scheduled
|
|||||||
from pobsync.paths import PobsyncPaths
|
from pobsync.paths import PobsyncPaths
|
||||||
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
|
||||||
|
from pobsync_backend.snapshot_discovery import infer_snapshot_kind, upsert_snapshot_record
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
@@ -73,6 +74,13 @@ class Command(BaseCommand):
|
|||||||
"result",
|
"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"):
|
if result.get("ok"):
|
||||||
self.stdout.write(self.style.SUCCESS(f"Backup completed for {host.host}."))
|
self.stdout.write(self.style.SUCCESS(f"Backup completed for {host.host}."))
|
||||||
|
|||||||
@@ -45,19 +45,10 @@ def discover_snapshots(
|
|||||||
host_root = resolve_host_root(global_config.backup_root, host_config.host)
|
host_root = resolve_host_root(global_config.backup_root, host_config.host)
|
||||||
for kind in kinds:
|
for kind in kinds:
|
||||||
for snapshot_dir in iter_snapshot_dirs(host_root, kind):
|
for snapshot_dir in iter_snapshot_dirs(host_root, kind):
|
||||||
meta = read_snapshot_meta(snapshot_dir)
|
_record, was_created = upsert_snapshot_record(
|
||||||
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(
|
|
||||||
host=host_config,
|
host=host_config,
|
||||||
kind=kind,
|
kind=kind,
|
||||||
dirname=snapshot_dir.name,
|
snapshot_dir=snapshot_dir,
|
||||||
defaults=defaults,
|
|
||||||
)
|
)
|
||||||
scanned += 1
|
scanned += 1
|
||||||
if was_created:
|
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:
|
def _parse_iso_z(value: str) -> datetime | None:
|
||||||
try:
|
try:
|
||||||
if value.endswith("Z"):
|
if value.endswith("Z"):
|
||||||
|
|||||||
102
src/pobsync_backend/tests/test_run_backup_records_snapshot.py
Normal file
102
src/pobsync_backend/tests/test_run_backup_records_snapshot.py
Normal file
@@ -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)
|
||||||
Reference in New Issue
Block a user